From 0603511b0f0a36e6b9566391d9ab43af1db800a6 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 21:01:35 +0200 Subject: [PATCH 01/10] feat: add polar billing --- packages/server/.env.example | 8 +- packages/server/package.json | 2 + packages/server/src/billing/entitlements.ts | 71 ++++++++ packages/server/src/billing/polar.ts | 7 + packages/server/src/billing/usage.ts | 19 +++ .../server/src/db/schemas/kinora-schemas.ts | 12 ++ packages/server/src/lib/auth.ts | 43 ++++- packages/server/src/lib/env.ts | 4 + packages/server/src/public-api/index.ts | 31 +++- pnpm-lock.yaml | 160 +++++++++++++++++- 10 files changed, 347 insertions(+), 10 deletions(-) create mode 100644 packages/server/src/billing/entitlements.ts create mode 100644 packages/server/src/billing/polar.ts create mode 100644 packages/server/src/billing/usage.ts diff --git a/packages/server/.env.example b/packages/server/.env.example index a40dc8a..63aabe3 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -18,4 +18,10 @@ GOOGLE_CLIENT_SECRET= # GitHub GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= \ No newline at end of file +GITHUB_CLIENT_SECRET= + +# Polar +POLAR_ACCESS_TOKEN= +POLAR_WEBHOOK_SECRET= +POLAR_PRODUCT_TEAM_ID= +POLAR_PRODUCT_PRO_ID= \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 7895c49..72cafaa 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -30,6 +30,8 @@ "@hono/trpc-server": "^0.4.2", "@hono/zod-validator": "^0.7.6", "@kinora/core": "workspace:*", + "@polar-sh/better-auth": "^1.8.4", + "@polar-sh/sdk": "^0.47.1", "@trpc/server": "^11.16.0", "better-auth": "^1.5.6", "dotenv": "^17.3.1", diff --git a/packages/server/src/billing/entitlements.ts b/packages/server/src/billing/entitlements.ts new file mode 100644 index 0000000..dc57327 --- /dev/null +++ b/packages/server/src/billing/entitlements.ts @@ -0,0 +1,71 @@ +import { eq } from 'drizzle-orm' +import { db } from '../db' +import { subscription } from '../db/schemas/index' +import { env } from '../lib/env' + +export type Tier = 'free' | 'team' | 'pro' | 'enterprise' + +export interface Entitlements { + tier: Tier + maxProjects: number + retentionDays: number + includedResults: number + alerts: boolean +} + +const LIMITS: Record> = { + free: { maxProjects: 1, retentionDays: 7, includedResults: 2_500, alerts: false }, + team: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 90, includedResults: 10_000, alerts: true }, + pro: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 365, includedResults: 50_000, alerts: true }, + enterprise: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: Number.POSITIVE_INFINITY, includedResults: Number.POSITIVE_INFINITY, alerts: true }, +} + +function tierForProduct(productId: string | undefined): Tier { + if (productId === env.POLAR_PRODUCT_TEAM_ID) + return 'team' + if (productId === env.POLAR_PRODUCT_PRO_ID) + return 'pro' + return 'free' +} + +interface CustomerStateInput { + userId: string | null | undefined + polarCustomerId: string + subscriptions: Array<{ + productId: string + status: string + currentPeriodEnd: Date | null + cancelAtPeriodEnd: boolean + }> +} + +export async function syncCustomerState(state: CustomerStateInput): Promise { + if (!state.userId) + return + + const sub = state.subscriptions.find(s => tierForProduct(s.productId) !== 'free') + const tier = tierForProduct(sub?.productId) + + const values = { + polarCustomerId: state.polarCustomerId, + tier, + status: sub?.status ?? null, + productId: sub?.productId ?? null, + currentPeriodEnd: sub?.currentPeriodEnd ?? null, + cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false, + } + + await db + .insert(subscription) + .values({ userId: state.userId, ...values }) + .onConflictDoUpdate({ target: subscription.userId, set: values }) +} + +export async function getEntitlements(userId: string): Promise { + const row = await db.query.subscription.findFirst({ + where: eq(subscription.userId, userId), + columns: { tier: true }, + }) + const tier = row?.tier ?? 'free' + return { tier, ...LIMITS[tier] } +} diff --git a/packages/server/src/billing/polar.ts b/packages/server/src/billing/polar.ts new file mode 100644 index 0000000..211f9b7 --- /dev/null +++ b/packages/server/src/billing/polar.ts @@ -0,0 +1,7 @@ +import { Polar } from '@polar-sh/sdk' +import { env } from '../lib/env' + +export const polarClient = new Polar({ + accessToken: env.POLAR_ACCESS_TOKEN, + server: env.NODE_ENV === 'production' ? 'production' : 'sandbox', +}) diff --git a/packages/server/src/billing/usage.ts b/packages/server/src/billing/usage.ts new file mode 100644 index 0000000..4b36cac --- /dev/null +++ b/packages/server/src/billing/usage.ts @@ -0,0 +1,19 @@ +import { and, count, eq, gte } from 'drizzle-orm' +import { db } from '../db' +import { project, test } from '../db/schemas/index' + +function startOfMonthUtc(): Date { + const now = new Date() + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) +} + +// Test results ingested by the user since the start of the current UTC month. +export async function currentPeriodResults(userId: string): Promise { + const [row] = await db + .select({ total: count() }) + .from(test) + .innerJoin(project, eq(test.projectId, project.id)) + .where(and(eq(project.userId, userId), gte(test.createdAt, startOfMonthUtc()))) + + return row?.total ?? 0 +} diff --git a/packages/server/src/db/schemas/kinora-schemas.ts b/packages/server/src/db/schemas/kinora-schemas.ts index 31143ca..848d5ed 100644 --- a/packages/server/src/db/schemas/kinora-schemas.ts +++ b/packages/server/src/db/schemas/kinora-schemas.ts @@ -70,6 +70,18 @@ export const artifact = pgTable('artifact', { createdAt: timestamp('created_at').defaultNow().notNull(), }, table => [index('artifact_runId_idx').on(table.runId)]) +// Cached Polar billing state, synced from the customer.state_changed webhook. +export const subscription = pgTable('subscription', { + userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), + polarCustomerId: text('polar_customer_id').notNull(), + tier: text('tier').$type<'free' | 'team' | 'pro' | 'enterprise'>().notNull().default('free'), + status: text('status'), + productId: text('product_id'), + currentPeriodEnd: timestamp('current_period_end'), + cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false), + updatedAt: timestamp('updated_at').defaultNow().$onUpdate(() => new Date()).notNull(), +}) + export const projectRelations = relations(project, ({ one, many }) => ({ user: one(user, { fields: [project.userId], references: [user.id] }), runs: many(run), diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 8009d82..7d67ea3 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -1,9 +1,13 @@ import { apiKey } from '@better-auth/api-key' +import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { lastLoginMethod } from 'better-auth/plugins' +import { syncCustomerState } from '../billing/entitlements' +import { polarClient } from '../billing/polar' import { db } from '../db' import { env } from './env' +import { logger } from './logger' import { getTrustedOrigins } from './utils' export const auth = betterAuth({ @@ -24,7 +28,44 @@ export const auth = betterAuth({ // No SMTP yet: emails stay unverified, so apply the new address directly instead of mailing a verification link. user: { changeEmail: { enabled: true, updateEmailWithoutVerification: true } }, secret: env.AUTH_SECRET, - plugins: [apiKey(), lastLoginMethod()], + plugins: [ + apiKey(), + lastLoginMethod(), + polar({ + client: polarClient, + createCustomerOnSignUp: true, + use: [ + checkout({ + products: [ + { productId: env.POLAR_PRODUCT_TEAM_ID, slug: 'team' }, + { productId: env.POLAR_PRODUCT_PRO_ID, slug: 'pro' }, + ], + successUrl: '/billing/success?checkout_id={CHECKOUT_ID}', + authenticatedUsersOnly: true, + }), + portal(), + usage(), + webhooks({ + secret: env.POLAR_WEBHOOK_SECRET, + onCustomerStateChanged: async ({ data }) => { + await syncCustomerState({ + userId: data.externalId, + polarCustomerId: data.id, + subscriptions: data.activeSubscriptions.map(s => ({ + productId: s.productId, + status: s.status, + currentPeriodEnd: s.currentPeriodEnd, + cancelAtPeriodEnd: s.cancelAtPeriodEnd, + })), + }) + }, + onPayload: async (payload) => { + logger.info({ event: payload.type }, 'polar webhook') + }, + }), + ], + }), + ], }) export interface AuthType { diff --git a/packages/server/src/lib/env.ts b/packages/server/src/lib/env.ts index 47de79d..9538aef 100644 --- a/packages/server/src/lib/env.ts +++ b/packages/server/src/lib/env.ts @@ -17,6 +17,10 @@ const envSchema = z.object({ GOOGLE_CLIENT_SECRET: z.string(), GITHUB_CLIENT_ID: z.string(), GITHUB_CLIENT_SECRET: z.string(), + POLAR_ACCESS_TOKEN: z.string(), + POLAR_WEBHOOK_SECRET: z.string(), + POLAR_PRODUCT_TEAM_ID: z.string(), + POLAR_PRODUCT_PRO_ID: z.string(), }) export type Env = z.infer diff --git a/packages/server/src/public-api/index.ts b/packages/server/src/public-api/index.ts index f222af2..3af9b83 100644 --- a/packages/server/src/public-api/index.ts +++ b/packages/server/src/public-api/index.ts @@ -4,15 +4,18 @@ import { zValidator } from '@hono/zod-validator' import { countsByTagFrom, ingestRunSchema } from '@kinora/core' import { and, eq } from 'drizzle-orm' import { Hono } from 'hono' +import { getEntitlements } from '../billing/entitlements' +import { polarClient } from '../billing/polar' +import { currentPeriodResults } from '../billing/usage' import { db } from '../db' import { artifact, project, run, test } from '../db/schemas/index' import { auth } from '../lib/auth' +import { logger } from '../lib/logger' import { storage } from '../lib/storage' const BEARER_PREFIX = 'Bearer ' // Public ingest API (api-key authed) - the reporter / cli upload here. -// Plain REST so any CI, curl, or language can hit it. export const publicApi = new Hono<{ Variables: { userId: string } }>() publicApi.use('*', async (c, next) => { @@ -33,6 +36,17 @@ publicApi.post('/runs', zValidator('json', ingestRunSchema), async (c) => { const userId = c.get('userId') const input = c.req.valid('json') + const entitlements = await getEntitlements(userId) + if (entitlements.tier === 'free') { + const used = await currentPeriodResults(userId) + if (used >= entitlements.includedResults) { + return c.json({ + error: 'Free plan monthly test-result limit reached. Upgrade to keep ingesting.', + limit: entitlements.includedResults, + }, 402) + } + } + const result = await db.transaction(async (tx) => { const existing = await tx.query.project.findFirst({ where: and(eq(project.userId, userId), eq(project.slug, input.project.slug)), @@ -90,6 +104,21 @@ publicApi.post('/runs', zValidator('json', ingestRunSchema), async (c) => { return { projectId, runId, tests: input.tests.length } }) + if (result.tests > 0) { + try { + await polarClient.events.ingest({ + events: [{ + name: 'test_results', + externalCustomerId: userId, + metadata: { results: result.tests }, + }], + }) + } + catch (error) { + logger.error({ error, userId, runId: result.runId }, 'polar usage ingest failed') + } + } + return c.json(result, 201) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d770aa4..1db50eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,7 +88,7 @@ importers: dependencies: '@better-auth/api-key': specifier: ^1.5.6 - version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(better-call@1.3.5(zod@4.4.3)) + version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(better-call@1.3.5(zod@4.4.3)) '@hono/node-server': specifier: ^2.0.4 version: 2.0.4(hono@4.12.23) @@ -101,12 +101,18 @@ importers: '@kinora/core': specifier: workspace:* version: link:../core + '@polar-sh/better-auth': + specifier: ^1.8.4 + version: 1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@stripe/stripe-js@7.9.0)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(react@19.2.7)(zod@4.4.3) + '@polar-sh/sdk': + specifier: ^0.47.1 + version: 0.47.1 '@trpc/server': specifier: ^11.16.0 version: 11.17.0(typescript@6.0.3) better-auth: specifier: ^1.5.6 - version: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) + version: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) dotenv: specifier: ^17.3.1 version: 17.4.2 @@ -274,7 +280,7 @@ importers: dependencies: '@better-auth/api-key': specifier: ^1.5.6 - version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(better-call@1.3.5(zod@4.4.3)) + version: 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(better-call@1.3.5(zod@4.4.3)) '@kinora/core': specifier: workspace:* version: link:../core @@ -301,7 +307,7 @@ importers: version: 14.3.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.35)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) better-auth: specifier: ^1.5.6 - version: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) + version: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1291,6 +1297,24 @@ packages: engines: {node: '>=18'} hasBin: true + '@polar-sh/better-auth@1.8.4': + resolution: {integrity: sha512-BgWCcBQDRYwQn2NEYYzGSEfnrmBJHaDTvYBXGE2V3mmGPKspUDS8+p1LzbNY0Sw681LT8HB5w5Q81Ysc46CVSQ==} + engines: {node: '>=16'} + peerDependencies: + '@polar-sh/sdk': ^0.47.0 + better-auth: ^1.4.12 + zod: ^3.25.0 || ^4.0.0 + + '@polar-sh/checkout@0.2.1': + resolution: {integrity: sha512-AEKKw3o4ykApECA4bU6a2YC0+aQKTFUasca4ZgCDIjG8AHlu3SjW3Taxh2nfeYAczhah0m0axVF9AV8VfdYzfw==} + peerDependencies: + '@stripe/react-stripe-js': ^3.6.0 || ^4.0.2 + '@stripe/stripe-js': ^7.1.0 + react: ^18 || ^19 + + '@polar-sh/sdk@0.47.1': + resolution: {integrity: sha512-fkz7wPLbqfuDmY9LxuXpE2uP2TAV6J0q/YN5hJ4UBxpjbkB0hKM6c4R35N89t83dfzMlG6EOlqOn+Rd1T6XrJQ==} + '@poppinss/cliui@6.8.1': resolution: {integrity: sha512-o/ssbwr+r6woG65rk9eFHnn9dVUphZr/Rk+4+05ENVMBWYpYhTJGdE9RobTG5JLFubvO4gWIyFeNlC+I4EM6eA==} @@ -1503,9 +1527,23 @@ packages: resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stripe/react-stripe-js@4.0.2': + resolution: {integrity: sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==} + peerDependencies: + '@stripe/stripe-js': '>=1.44.1 <8.0.0' + react: '>=16.8.0 <20.0.0' + react-dom: '>=16.8.0 <20.0.0' + + '@stripe/stripe-js@7.9.0': + resolution: {integrity: sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==} + engines: {node: '>=12.16'} + '@stylistic/eslint-plugin@5.10.0': resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2192,6 +2230,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -2642,6 +2683,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -2821,6 +2865,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdoc-type-pratt-parser@7.1.1: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} @@ -2963,6 +3010,10 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lucide-vue-next@1.0.0: resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==} deprecated: Package deprecated. Please use @lucide/vue instead. @@ -3165,6 +3216,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-deep-merge@2.0.1: resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} @@ -3348,6 +3403,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3370,6 +3428,18 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -3459,6 +3529,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -3533,6 +3606,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -4042,11 +4118,11 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.6 '@babel/helper-validator-identifier': 8.0.0-rc.6 - '@better-auth/api-key@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(better-call@1.3.5(zod@4.4.3))': + '@better-auth/api-key@1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(better-call@1.3.5(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 - better-auth: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) + better-auth: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) better-call: 1.3.5(zod@4.4.3) zod: 4.4.3 @@ -4658,6 +4734,29 @@ snapshots: dependencies: playwright: 1.60.0 + '@polar-sh/better-auth@1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@stripe/stripe-js@7.9.0)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(react@19.2.7)(zod@4.4.3)': + dependencies: + '@polar-sh/checkout': 0.2.1(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@stripe/stripe-js@7.9.0)(react@19.2.7) + '@polar-sh/sdk': 0.47.1 + better-auth: 1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)) + zod: 4.4.3 + transitivePeerDependencies: + - '@stripe/react-stripe-js' + - '@stripe/stripe-js' + - react + + '@polar-sh/checkout@0.2.1(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@stripe/stripe-js@7.9.0)(react@19.2.7)': + dependencies: + '@stripe/react-stripe-js': 4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@stripe/stripe-js': 7.9.0 + date-fns: 4.4.0 + react: 19.2.7 + + '@polar-sh/sdk@0.47.1': + dependencies: + standardwebhooks: 1.0.0 + zod: 4.4.3 + '@poppinss/cliui@6.8.1': dependencies: '@poppinss/colors': 4.1.6 @@ -4786,8 +4885,19 @@ snapshots: '@sindresorhus/base62@1.0.0': {} + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@stripe/stripe-js': 7.9.0 + prop-types: 15.8.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@stripe/stripe-js@7.9.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@10.4.1(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1(jiti@2.7.0)) @@ -5333,7 +5443,7 @@ snapshots: baseline-browser-mapping@2.10.33: {} - better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)): + better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)): dependencies: '@better-auth/core': 1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.14(@better-auth/core@1.6.14(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0)) @@ -5356,6 +5466,8 @@ snapshots: drizzle-kit: 0.31.10 drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0) pg: 8.21.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) vitest: 4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vue: 3.5.35(typescript@6.0.3) transitivePeerDependencies: @@ -5488,6 +5600,8 @@ snapshots: csstype@3.2.3: {} + date-fns@4.4.0: {} + dateformat@4.6.3: {} debug@4.4.3: @@ -5964,6 +6078,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -6094,6 +6210,8 @@ snapshots: joycon@3.1.1: {} + js-tokens@4.0.0: {} + jsdoc-type-pratt-parser@7.1.1: {} jsdoc-type-pratt-parser@7.2.0: {} @@ -6202,6 +6320,10 @@ snapshots: longest-streak@3.1.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lucide-vue-next@1.0.0(vue@3.5.35(typescript@6.0.3)): dependencies: vue: 3.5.35(typescript@6.0.3) @@ -6593,6 +6715,8 @@ snapshots: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + object-deep-merge@2.0.1: {} obug@2.1.2: {} @@ -6778,6 +6902,12 @@ snapshots: process-warning@5.0.0: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -6795,6 +6925,15 @@ snapshots: quick-format-unescaped@4.0.4: {} + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react@19.2.7: {} + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -6911,6 +7050,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + scheduler@0.27.0: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -6970,6 +7111,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@4.1.0: {} string-width@4.2.3: From fa09eb933856236926ea0e37f05c7295a9df1d98 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 21:28:16 +0200 Subject: [PATCH 02/10] feat: add cloud flag --- packages/server/.env.example | 5 +- packages/server/src/billing/entitlements.ts | 12 +++-- packages/server/src/billing/polar.ts | 55 +++++++++++++++++++-- packages/server/src/lib/auth.ts | 46 ++--------------- packages/server/src/lib/env.ts | 39 +++++++++++++-- packages/server/src/public-api/index.ts | 2 +- 6 files changed, 102 insertions(+), 57 deletions(-) diff --git a/packages/server/.env.example b/packages/server/.env.example index 63aabe3..c16bc2e 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -20,7 +20,10 @@ GOOGLE_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# Polar +# Cloud mode: true enables Polar billing. Self-host leaves this false +KINORA_CLOUD=false + +# Polar (required only when KINORA_CLOUD=true) POLAR_ACCESS_TOKEN= POLAR_WEBHOOK_SECRET= POLAR_PRODUCT_TEAM_ID= diff --git a/packages/server/src/billing/entitlements.ts b/packages/server/src/billing/entitlements.ts index dc57327..faac63d 100644 --- a/packages/server/src/billing/entitlements.ts +++ b/packages/server/src/billing/entitlements.ts @@ -1,9 +1,9 @@ import { eq } from 'drizzle-orm' import { db } from '../db' import { subscription } from '../db/schemas/index' -import { env } from '../lib/env' +import { cloud, env } from '../lib/env' -export type Tier = 'free' | 'team' | 'pro' | 'enterprise' +export type Tier = 'free' | 'team' | 'pro' | 'enterprise' | 'selfhost' export interface Entitlements { tier: Tier @@ -18,9 +18,12 @@ const LIMITS: Record> = { team: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 90, includedResults: 10_000, alerts: true }, pro: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: 365, includedResults: 50_000, alerts: true }, enterprise: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: Number.POSITIVE_INFINITY, includedResults: Number.POSITIVE_INFINITY, alerts: true }, + selfhost: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: Number.POSITIVE_INFINITY, includedResults: Number.POSITIVE_INFINITY, alerts: true }, } -function tierForProduct(productId: string | undefined): Tier { +function tierForProduct(productId: string | undefined): 'free' | 'team' | 'pro' { + if (!productId) + return 'free' if (productId === env.POLAR_PRODUCT_TEAM_ID) return 'team' if (productId === env.POLAR_PRODUCT_PRO_ID) @@ -62,6 +65,9 @@ export async function syncCustomerState(state: CustomerStateInput): Promise { + if (!cloud) + return { tier: 'selfhost', ...LIMITS.selfhost } + const row = await db.query.subscription.findFirst({ where: eq(subscription.userId, userId), columns: { tier: true }, diff --git a/packages/server/src/billing/polar.ts b/packages/server/src/billing/polar.ts index 211f9b7..f4a49ae 100644 --- a/packages/server/src/billing/polar.ts +++ b/packages/server/src/billing/polar.ts @@ -1,7 +1,52 @@ +import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth' import { Polar } from '@polar-sh/sdk' -import { env } from '../lib/env' +import { cloud, env } from '../lib/env' +import { logger } from '../lib/logger' +import { syncCustomerState } from './entitlements' -export const polarClient = new Polar({ - accessToken: env.POLAR_ACCESS_TOKEN, - server: env.NODE_ENV === 'production' ? 'production' : 'sandbox', -}) +export const polarClient = cloud + ? new Polar({ + accessToken: cloud.accessToken, + server: env.NODE_ENV === 'production' ? 'production' : 'sandbox', + }) + : null + +export function polarAuthPlugin() { + if (!cloud || !polarClient) + return null + + return polar({ + client: polarClient, + createCustomerOnSignUp: true, + use: [ + checkout({ + products: [ + { productId: cloud.teamProductId, slug: 'team' }, + { productId: cloud.proProductId, slug: 'pro' }, + ], + successUrl: '/billing/success?checkout_id={CHECKOUT_ID}', + authenticatedUsersOnly: true, + }), + portal(), + usage(), + webhooks({ + secret: cloud.webhookSecret, + onCustomerStateChanged: async ({ data }) => { + await syncCustomerState({ + userId: data.externalId, + polarCustomerId: data.id, + subscriptions: data.activeSubscriptions.map(s => ({ + productId: s.productId, + status: s.status, + currentPeriodEnd: s.currentPeriodEnd, + cancelAtPeriodEnd: s.cancelAtPeriodEnd, + })), + }) + }, + onPayload: async (payload) => { + logger.info({ event: payload.type }, 'polar webhook') + }, + }), + ], + }) +} diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 7d67ea3..b5d28d4 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -1,13 +1,10 @@ import { apiKey } from '@better-auth/api-key' -import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { lastLoginMethod } from 'better-auth/plugins' -import { syncCustomerState } from '../billing/entitlements' -import { polarClient } from '../billing/polar' +import { polarAuthPlugin } from '../billing/polar' import { db } from '../db' import { env } from './env' -import { logger } from './logger' import { getTrustedOrigins } from './utils' export const auth = betterAuth({ @@ -28,44 +25,9 @@ export const auth = betterAuth({ // No SMTP yet: emails stay unverified, so apply the new address directly instead of mailing a verification link. user: { changeEmail: { enabled: true, updateEmailWithoutVerification: true } }, secret: env.AUTH_SECRET, - plugins: [ - apiKey(), - lastLoginMethod(), - polar({ - client: polarClient, - createCustomerOnSignUp: true, - use: [ - checkout({ - products: [ - { productId: env.POLAR_PRODUCT_TEAM_ID, slug: 'team' }, - { productId: env.POLAR_PRODUCT_PRO_ID, slug: 'pro' }, - ], - successUrl: '/billing/success?checkout_id={CHECKOUT_ID}', - authenticatedUsersOnly: true, - }), - portal(), - usage(), - webhooks({ - secret: env.POLAR_WEBHOOK_SECRET, - onCustomerStateChanged: async ({ data }) => { - await syncCustomerState({ - userId: data.externalId, - polarCustomerId: data.id, - subscriptions: data.activeSubscriptions.map(s => ({ - productId: s.productId, - status: s.status, - currentPeriodEnd: s.currentPeriodEnd, - cancelAtPeriodEnd: s.cancelAtPeriodEnd, - })), - }) - }, - onPayload: async (payload) => { - logger.info({ event: payload.type }, 'polar webhook') - }, - }), - ], - }), - ], + plugins: [apiKey(), lastLoginMethod(), polarAuthPlugin()].filter( + (plugin): plugin is NonNullable => plugin !== null, + ), }) export interface AuthType { diff --git a/packages/server/src/lib/env.ts b/packages/server/src/lib/env.ts index 9538aef..d27c2e6 100644 --- a/packages/server/src/lib/env.ts +++ b/packages/server/src/lib/env.ts @@ -17,12 +17,41 @@ const envSchema = z.object({ GOOGLE_CLIENT_SECRET: z.string(), GITHUB_CLIENT_ID: z.string(), GITHUB_CLIENT_SECRET: z.string(), - POLAR_ACCESS_TOKEN: z.string(), - POLAR_WEBHOOK_SECRET: z.string(), - POLAR_PRODUCT_TEAM_ID: z.string(), - POLAR_PRODUCT_PRO_ID: z.string(), -}) + KINORA_CLOUD: z.stringbool().default(false), + POLAR_ACCESS_TOKEN: z.string().optional(), + POLAR_WEBHOOK_SECRET: z.string().optional(), + POLAR_PRODUCT_TEAM_ID: z.string().optional(), + POLAR_PRODUCT_PRO_ID: z.string().optional(), +}).refine( + e => !e.KINORA_CLOUD || Boolean(e.POLAR_ACCESS_TOKEN && e.POLAR_WEBHOOK_SECRET && e.POLAR_PRODUCT_TEAM_ID && e.POLAR_PRODUCT_PRO_ID), + { message: 'KINORA_CLOUD=true requires POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_PRODUCT_TEAM_ID and POLAR_PRODUCT_PRO_ID' }, +) export type Env = z.infer export const env = envSchema.parse(process.env) + +export interface CloudConfig { + accessToken: string + webhookSecret: string + teamProductId: string + proProductId: string +} + +function resolveCloud(): CloudConfig | null { + if (!env.KINORA_CLOUD) + return null + + const { POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_PRODUCT_TEAM_ID, POLAR_PRODUCT_PRO_ID } = env + if (!POLAR_ACCESS_TOKEN || !POLAR_WEBHOOK_SECRET || !POLAR_PRODUCT_TEAM_ID || !POLAR_PRODUCT_PRO_ID) + throw new Error('KINORA_CLOUD=true requires all POLAR_* env vars') + + return { + accessToken: POLAR_ACCESS_TOKEN, + webhookSecret: POLAR_WEBHOOK_SECRET, + teamProductId: POLAR_PRODUCT_TEAM_ID, + proProductId: POLAR_PRODUCT_PRO_ID, + } +} + +export const cloud = resolveCloud() diff --git a/packages/server/src/public-api/index.ts b/packages/server/src/public-api/index.ts index 3af9b83..1c35494 100644 --- a/packages/server/src/public-api/index.ts +++ b/packages/server/src/public-api/index.ts @@ -104,7 +104,7 @@ publicApi.post('/runs', zValidator('json', ingestRunSchema), async (c) => { return { projectId, runId, tests: input.tests.length } }) - if (result.tests > 0) { + if (polarClient && result.tests > 0) { try { await polarClient.events.ingest({ events: [{ From eb01525fde70dec352e5c47b174e5042a562cb8a Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 21:46:16 +0200 Subject: [PATCH 03/10] test: add inte test for billing usage --- .github/workflows/ci.yml | 1 + packages/server/test/setup-env.ts | 2 - packages/server/test/test-env.ts | 9 ++-- packages/server/test/usage.test.ts | 74 ++++++++++++++++++++++++++++++ packages/server/tsconfig.json | 2 +- 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 packages/server/test/usage.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c91ee..771b780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,7 @@ jobs: GOOGLE_CLIENT_SECRET: '' GITHUB_CLIENT_ID: '' GITHUB_CLIENT_SECRET: '' + KINORA_CLOUD: 'false' steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/packages/server/test/setup-env.ts b/packages/server/test/setup-env.ts index 6c016b3..ce1d030 100644 --- a/packages/server/test/setup-env.ts +++ b/packages/server/test/setup-env.ts @@ -1,7 +1,5 @@ import process from 'node:process' import { TEST_ENV } from './test-env' -// First setup file: populate process.env before any test module imports env.ts. -// Direct assignment overrides Vite's built-in process.env.BASE_URL ('/'), which fails z.url(). for (const [key, value] of Object.entries(TEST_ENV)) process.env[key] = value diff --git a/packages/server/test/test-env.ts b/packages/server/test/test-env.ts index 80b31fd..d006832 100644 --- a/packages/server/test/test-env.ts +++ b/packages/server/test/test-env.ts @@ -1,10 +1,6 @@ import type { Env } from '../src/lib/env' import process from 'node:process' -// Self-contained env for integration tests: no .env needed. Typed against Env so -// every required key is set. Only the Postgres connection has to be real (dev -// compose defaults; override via process.env in CI). POSTGRES_DB is pinned to -// kinora_test so the dev DB is never reachable; the rest are throwaway values. export const TEST_ENV: Record = { NODE_ENV: 'development', PORT: '3000', @@ -20,4 +16,9 @@ export const TEST_ENV: Record = { GOOGLE_CLIENT_SECRET: 'test', GITHUB_CLIENT_ID: 'test', GITHUB_CLIENT_SECRET: 'test', + KINORA_CLOUD: 'false', + POLAR_ACCESS_TOKEN: '', + POLAR_WEBHOOK_SECRET: '', + POLAR_PRODUCT_TEAM_ID: '', + POLAR_PRODUCT_PRO_ID: '', } diff --git a/packages/server/test/usage.test.ts b/packages/server/test/usage.test.ts new file mode 100644 index 0000000..d24351e --- /dev/null +++ b/packages/server/test/usage.test.ts @@ -0,0 +1,74 @@ +import { randomUUID } from 'node:crypto' +import { beforeEach, describe, expect, it } from 'vitest' +import { currentPeriodResults } from '../src/billing/usage' +import { db } from '../src/db' +import { project, run, test as testRow } from '../src/db/schemas/index' +import { createUser, resetDb } from './helpers' + +beforeEach(resetDb) + +function lastInstantOfPreviousMonthUtc(): Date { + const now = new Date() + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) - 1) +} + +async function seedTests(userId: string, count: number, createdAt?: Date): Promise { + const projectId = randomUUID() + await db.insert(project).values({ id: projectId, userId, slug: `slug-${projectId}`, name: 'p' }) + + const runId = randomUUID() + await db.insert(run).values({ + id: runId, + projectId, + startedAt: new Date(), + duration: 0, + counts: { total: count, expected: count, unexpected: 0, flaky: 0, skipped: 0 }, + }) + + await db.insert(testRow).values( + Array.from({ length: count }, (_, i) => ({ + id: randomUUID(), + runId, + projectId, + testKey: `key-${runId}-${i}`, + title: 't', + titlePath: ['file.ts', 't'], + file: 'file.ts', + line: 1, + column: 1, + projectName: 'chromium', + status: 'expected', + ok: true, + duration: 0, + retries: 0, + tags: [], + annotations: [], + errors: [], + attachments: [], + ...(createdAt ? { createdAt } : {}), + })), + ) +} + +describe('currentPeriodResults', () => { + it('counts this-month test rows for the user', async () => { + const user = await createUser() + await seedTests(user.id, 3) + expect(await currentPeriodResults(user.id)).toBe(3) + }) + + it('excludes test rows from previous months', async () => { + const user = await createUser() + await seedTests(user.id, 3) + await seedTests(user.id, 5, lastInstantOfPreviousMonthUtc()) + expect(await currentPeriodResults(user.id)).toBe(3) + }) + + it('scopes the count to the given user', async () => { + const user = await createUser() + const other = await createUser('other@test.dev') + await seedTests(user.id, 2) + await seedTests(other.id, 4) + expect(await currentPeriodResults(user.id)).toBe(2) + }) +}) diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 2f1f5b2..a1e327b 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,5 +4,5 @@ "lib": ["ES2023"], "types": ["node"] }, - "include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle.config.ts", "tsdown.config.ts"] + "include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts", "drizzle.config.ts", "drizzle.test.config.ts", "tsdown.config.ts", "vitest.config.ts"] } From 4046153d46f55d358b136910830456bfba08e293 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 22:20:48 +0200 Subject: [PATCH 04/10] feat: settings add billing section --- packages/server/src/billing/polar.ts | 3 +- packages/server/src/router/billing.ts | 27 +++++ packages/server/src/router/index.ts | 2 + packages/web/package.json | 2 + packages/web/src/composables/useBilling.ts | 42 ++++++++ packages/web/src/lib/auth.ts | 3 +- packages/web/src/pages/SettingsPage.vue | 111 ++++++++++++++++++++- pnpm-lock.yaml | 6 ++ 8 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/router/billing.ts create mode 100644 packages/web/src/composables/useBilling.ts diff --git a/packages/server/src/billing/polar.ts b/packages/server/src/billing/polar.ts index f4a49ae..e2d6c26 100644 --- a/packages/server/src/billing/polar.ts +++ b/packages/server/src/billing/polar.ts @@ -2,6 +2,7 @@ import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth' import { Polar } from '@polar-sh/sdk' import { cloud, env } from '../lib/env' import { logger } from '../lib/logger' +import { getTrustedOrigins } from '../lib/utils' import { syncCustomerState } from './entitlements' export const polarClient = cloud @@ -24,7 +25,7 @@ export function polarAuthPlugin() { { productId: cloud.teamProductId, slug: 'team' }, { productId: cloud.proProductId, slug: 'pro' }, ], - successUrl: '/billing/success?checkout_id={CHECKOUT_ID}', + successUrl: `${getTrustedOrigins()[0] ?? ''}/settings?checkout=success`, authenticatedUsersOnly: true, }), portal(), diff --git a/packages/server/src/router/billing.ts b/packages/server/src/router/billing.ts new file mode 100644 index 0000000..447cc00 --- /dev/null +++ b/packages/server/src/router/billing.ts @@ -0,0 +1,27 @@ +import { getEntitlements } from '../billing/entitlements' +import { currentPeriodResults } from '../billing/usage' +import { authProcedure, router } from '../trpc/index' + +// Infinity doesn't survive JSON: unlimited limits go over the wire as null. +function finiteOrNull(n: number): number | null { + return Number.isFinite(n) ? n : null +} + +export const billingRouter = router({ + summary: authProcedure.query(async ({ ctx }) => { + const userId = ctx.user.id + const [entitlements, usedResults] = await Promise.all([ + getEntitlements(userId), + currentPeriodResults(userId), + ]) + + return { + tier: entitlements.tier, + alerts: entitlements.alerts, + maxProjects: finiteOrNull(entitlements.maxProjects), + retentionDays: finiteOrNull(entitlements.retentionDays), + includedResults: finiteOrNull(entitlements.includedResults), + usedResults, + } + }), +}) diff --git a/packages/server/src/router/index.ts b/packages/server/src/router/index.ts index 710d2ad..4696301 100644 --- a/packages/server/src/router/index.ts +++ b/packages/server/src/router/index.ts @@ -1,10 +1,12 @@ import { router } from '../trpc/index' +import { billingRouter } from './billing' import { dashboardRouter } from './dashboard' import { userRouter } from './user' export const appRouter = router({ user: userRouter, dashboard: dashboardRouter, + billing: billingRouter, }) export type AppRouter = typeof appRouter diff --git a/packages/web/package.json b/packages/web/package.json index ee0019d..ed887e9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -18,6 +18,8 @@ "@kinora/core": "workspace:*", "@kinora/ui": "workspace:*", "@lucide/vue": "^1.17.0", + "@polar-sh/better-auth": "^1.8.4", + "@polar-sh/sdk": "^0.47.1", "@tanstack/vue-table": "^8.21.3", "@trpc/client": "^11.16.0", "@vee-validate/zod": "^4.15.1", diff --git a/packages/web/src/composables/useBilling.ts b/packages/web/src/composables/useBilling.ts new file mode 100644 index 0000000..65ee8e2 --- /dev/null +++ b/packages/web/src/composables/useBilling.ts @@ -0,0 +1,42 @@ +import { useAsyncState } from '@vueuse/core' +import { ref } from 'vue' +import { toast } from 'vue-sonner' +import { authClient } from '@/lib/auth' +import { trpc } from '@/lib/trpc' + +export function useBilling() { + const { state: summary, isLoading, execute: refresh } = useAsyncState( + () => trpc.billing.summary.query(), + null, + { immediate: true }, + ) + + // Which billing action is mid-flight, so the buttons can disable + show progress. + const pending = ref<'team' | 'pro' | 'portal' | null>(null) + + async function checkout(slug: 'team' | 'pro'): Promise { + pending.value = slug + try { + const { error } = await authClient.checkout({ slug }) + if (error) + toast.error(error.message ?? 'Could not start checkout') + } + finally { + pending.value = null + } + } + + async function openPortal(): Promise { + pending.value = 'portal' + try { + const { error } = await authClient.customer.portal() + if (error) + toast.error(error.message ?? 'Could not open the billing portal') + } + finally { + pending.value = null + } + } + + return { summary, isLoading, refresh, pending, checkout, openPortal } +} diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts index 848460b..af3c9c7 100644 --- a/packages/web/src/lib/auth.ts +++ b/packages/web/src/lib/auth.ts @@ -1,9 +1,10 @@ import { apiKeyClient } from '@better-auth/api-key/client' +import { polarClient } from '@polar-sh/better-auth/client' import { lastLoginMethodClient } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/vue' import { env } from '@/lib/env' export const authClient = createAuthClient({ baseURL: env.serverUrl, - plugins: [apiKeyClient(), lastLoginMethodClient()], + plugins: [apiKeyClient(), lastLoginMethodClient(), polarClient()], }) diff --git a/packages/web/src/pages/SettingsPage.vue b/packages/web/src/pages/SettingsPage.vue index 8e99bee..634c52d 100644 --- a/packages/web/src/pages/SettingsPage.vue +++ b/packages/web/src/pages/SettingsPage.vue @@ -6,12 +6,14 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kinor import { Input } from '@kinora/ui/input' import { colorMode } from '@kinora/ui/theme' import { toTypedSchema } from '@vee-validate/zod' -import { Check, Copy, KeyRound, Monitor, Moon, Plus, Sun, Trash2 } from 'lucide-vue-next' +import { ArrowUpRight, Check, Copy, CreditCard, KeyRound, Monitor, Moon, Plus, Sun, Trash2 } from 'lucide-vue-next' import { useForm } from 'vee-validate' -import { computed, ref } from 'vue' +import { computed, onMounted, ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' import { z } from 'zod' import { useApiTokens } from '@/composables/useApiTokens' +import { useBilling } from '@/composables/useBilling' import { authClient } from '@/lib/auth' import { session } from '@/lib/session' @@ -102,6 +104,54 @@ async function createToken(): Promise { newTokenName.value = '' } +// --- Plan & billing --- +const { summary: billing, refresh: refreshBilling, pending: billingPending, checkout, openPortal } = useBilling() +const route = useRoute() +const router = useRouter() + +const TIER_LABELS: Record = { + free: 'Free', + team: 'Team', + pro: 'Pro', + enterprise: 'Enterprise', + selfhost: 'Self-host', +} + +const isPaid = computed(() => ['team', 'pro', 'enterprise'].includes(billing.value?.tier ?? '')) + +const usagePct = computed(() => { + const b = billing.value + if (!b || b.includedResults == null) + return 0 + return Math.min(100, Math.round((b.usedResults / b.includedResults) * 100)) +}) + +const overCap = computed(() => { + const b = billing.value + return !!b && b.includedResults != null && b.usedResults >= b.includedResults +}) + +const upgradeOptions = computed(() => { + const tier = billing.value?.tier + if (tier === 'free') { + return [ + { slug: 'team' as const, label: 'Upgrade to Team - $49/mo', featured: true }, + { slug: 'pro' as const, label: 'Upgrade to Pro - $149/mo', featured: false }, + ] + } + if (tier === 'team') + return [{ slug: 'pro' as const, label: 'Upgrade to Pro - $149/mo', featured: true }] + return [] +}) + +onMounted(() => { + if (route.query.checkout === 'success') { + toast.success('Subscription active. It may take a moment to reflect here.') + void refreshBilling() + void router.replace({ query: {} }) + } +}) + function fmtDate(d: Date | string | null | undefined): string { if (!d) return '-' @@ -116,10 +166,65 @@ function fmtDate(d: Date | string | null | undefined): string { Settings

- Appearance, account, and API tokens for pushing reports from CI. + Plan, appearance, account, and API tokens for pushing reports from CI.

+ + + + Plan + Your subscription and monthly test-result usage. + + +
+
+
+ +
+ +
+
+ Test results · this month + + {{ billing.usedResults.toLocaleString() }} + +
+
+
+
+

+ Monthly limit reached - upgrade to keep ingesting. +

+
+ +
+ +
+ + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1db50eb..3c0101b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,12 @@ importers: '@lucide/vue': specifier: ^1.17.0 version: 1.17.0(vue@3.5.35(typescript@6.0.3)) + '@polar-sh/better-auth': + specifier: ^1.8.4 + version: 1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@stripe/stripe-js@7.9.0)(better-auth@1.6.14(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.8(@types/node@24.13.0)(vite@8.0.16(@types/node@24.13.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vue@3.5.35(typescript@6.0.3)))(react@19.2.7)(zod@4.4.3) + '@polar-sh/sdk': + specifier: ^0.47.1 + version: 0.47.1 '@tanstack/vue-table': specifier: ^8.21.3 version: 8.21.3(vue@3.5.35(typescript@6.0.3)) From 77ea7d815127860f36d042d6cd3c06fedb3ece83 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 22:25:32 +0200 Subject: [PATCH 05/10] feat: settings add trial info --- packages/server/src/billing/entitlements.ts | 17 +++++++++++++++++ packages/server/src/router/billing.ts | 8 ++++++-- packages/web/src/pages/SettingsPage.vue | 20 +++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/server/src/billing/entitlements.ts b/packages/server/src/billing/entitlements.ts index faac63d..d812128 100644 --- a/packages/server/src/billing/entitlements.ts +++ b/packages/server/src/billing/entitlements.ts @@ -64,6 +64,23 @@ export async function syncCustomerState(state: CustomerStateInput): Promise { + if (!cloud) + return null + + const row = await db.query.subscription.findFirst({ + where: eq(subscription.userId, userId), + columns: { status: true, currentPeriodEnd: true, cancelAtPeriodEnd: true }, + }) + return row ?? null +} + export async function getEntitlements(userId: string): Promise { if (!cloud) return { tier: 'selfhost', ...LIMITS.selfhost } diff --git a/packages/server/src/router/billing.ts b/packages/server/src/router/billing.ts index 447cc00..45a4818 100644 --- a/packages/server/src/router/billing.ts +++ b/packages/server/src/router/billing.ts @@ -1,4 +1,4 @@ -import { getEntitlements } from '../billing/entitlements' +import { getEntitlements, getSubscription } from '../billing/entitlements' import { currentPeriodResults } from '../billing/usage' import { authProcedure, router } from '../trpc/index' @@ -10,8 +10,9 @@ function finiteOrNull(n: number): number | null { export const billingRouter = router({ summary: authProcedure.query(async ({ ctx }) => { const userId = ctx.user.id - const [entitlements, usedResults] = await Promise.all([ + const [entitlements, sub, usedResults] = await Promise.all([ getEntitlements(userId), + getSubscription(userId), currentPeriodResults(userId), ]) @@ -22,6 +23,9 @@ export const billingRouter = router({ retentionDays: finiteOrNull(entitlements.retentionDays), includedResults: finiteOrNull(entitlements.includedResults), usedResults, + status: sub?.status ?? null, + currentPeriodEnd: sub?.currentPeriodEnd?.toISOString() ?? null, + cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false, } }), }) diff --git a/packages/web/src/pages/SettingsPage.vue b/packages/web/src/pages/SettingsPage.vue index 634c52d..02b7606 100644 --- a/packages/web/src/pages/SettingsPage.vue +++ b/packages/web/src/pages/SettingsPage.vue @@ -144,6 +144,21 @@ const upgradeOptions = computed(() => { return [] }) +const planNote = computed<{ text: string, tone: string } | null>(() => { + const b = billing.value + if (!b || !b.status) + return null + if (b.status === 'trialing') + return { text: `Trial · ends ${fmtDate(b.currentPeriodEnd)}`, tone: 'text-signal' } + if (b.cancelAtPeriodEnd && b.currentPeriodEnd) + return { text: `Cancels ${fmtDate(b.currentPeriodEnd)}`, tone: 'text-fail' } + if (b.status === 'past_due') + return { text: 'Payment past due', tone: 'text-fail' } + if (b.currentPeriodEnd) + return { text: `Renews ${fmtDate(b.currentPeriodEnd)}`, tone: 'text-muted-foreground' } + return null +}) + onMounted(() => { if (route.query.checkout === 'success') { toast.success('Subscription active. It may take a moment to reflect here.') @@ -180,7 +195,10 @@ function fmtDate(d: Date | string | null | undefined): string {
+ +
From 74624354083d3bd144a779b6421296ee0ec53a8b Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 22:37:40 +0200 Subject: [PATCH 07/10] feat: max projects gating --- packages/server/src/billing/usage.ts | 9 +++++++++ packages/server/src/public-api/index.ts | 20 +++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/server/src/billing/usage.ts b/packages/server/src/billing/usage.ts index 4b36cac..a092a04 100644 --- a/packages/server/src/billing/usage.ts +++ b/packages/server/src/billing/usage.ts @@ -17,3 +17,12 @@ export async function currentPeriodResults(userId: string): Promise { return row?.total ?? 0 } + +export async function projectCount(userId: string): Promise { + const [row] = await db + .select({ total: count() }) + .from(project) + .where(eq(project.userId, userId)) + + return row?.total ?? 0 +} diff --git a/packages/server/src/public-api/index.ts b/packages/server/src/public-api/index.ts index 1c35494..f913b11 100644 --- a/packages/server/src/public-api/index.ts +++ b/packages/server/src/public-api/index.ts @@ -6,7 +6,7 @@ import { and, eq } from 'drizzle-orm' import { Hono } from 'hono' import { getEntitlements } from '../billing/entitlements' import { polarClient } from '../billing/polar' -import { currentPeriodResults } from '../billing/usage' +import { currentPeriodResults, projectCount } from '../billing/usage' import { db } from '../db' import { artifact, project, run, test } from '../db/schemas/index' import { auth } from '../lib/auth' @@ -38,6 +38,7 @@ publicApi.post('/runs', zValidator('json', ingestRunSchema), async (c) => { const entitlements = await getEntitlements(userId) if (entitlements.tier === 'free') { + // Cap ingested test results: blocks ingesting more if the monthly limit is already reached, but doesn't block if the limit is exceeded after ingesting. const used = await currentPeriodResults(userId) if (used >= entitlements.includedResults) { return c.json({ @@ -47,6 +48,23 @@ publicApi.post('/runs', zValidator('json', ingestRunSchema), async (c) => { } } + // Cap distinct projects: only blocks creating a new one beyond the plan limit. + if (Number.isFinite(entitlements.maxProjects)) { + const existing = await db.query.project.findFirst({ + where: and(eq(project.userId, userId), eq(project.slug, input.project.slug)), + columns: { id: true }, + }) + if (!existing) { + const projects = await projectCount(userId) + if (projects >= entitlements.maxProjects) { + return c.json({ + error: 'Plan project limit reached. Upgrade to add more projects.', + limit: entitlements.maxProjects, + }, 402) + } + } + } + const result = await db.transaction(async (tx) => { const existing = await tx.query.project.findFirst({ where: and(eq(project.userId, userId), eq(project.slug, input.project.slug)), From 9785b6d984485ebd131798ee5de899060bf89068 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 22:53:49 +0200 Subject: [PATCH 08/10] feat: purge expired runs --- packages/server/package.json | 3 +- packages/server/scripts/purge-expired-runs.ts | 13 +++ packages/server/src/billing/entitlements.ts | 4 + packages/server/src/billing/retention.ts | 91 +++++++++++++++++++ packages/server/src/lib/storage.ts | 9 +- packages/web/src/pages/SettingsPage.vue | 3 + 6 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 packages/server/scripts/purge-expired-runs.ts create mode 100644 packages/server/src/billing/retention.ts diff --git a/packages/server/package.json b/packages/server/package.json index 72cafaa..443ebbd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,7 +22,8 @@ "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:seed": "tsx scripts/seed.ts", - "db:seed:market": "tsx scripts/seed-market.ts" + "db:seed:market": "tsx scripts/seed-market.ts", + "purge-expired-runs": "tsx scripts/purge-expired-runs.ts" }, "dependencies": { "@better-auth/api-key": "^1.5.6", diff --git a/packages/server/scripts/purge-expired-runs.ts b/packages/server/scripts/purge-expired-runs.ts new file mode 100644 index 0000000..b4a8386 --- /dev/null +++ b/packages/server/scripts/purge-expired-runs.ts @@ -0,0 +1,13 @@ +import process from 'node:process' +import { purgeExpiredRuns } from '../src/billing/retention' +import { logger } from '../src/lib/logger' + +purgeExpiredRuns(new Date()) + .then(({ deleted }) => { + logger.info({ deleted }, 'purge-expired-runs complete') + process.exit(0) + }) + .catch((error) => { + logger.error({ error }, 'purge-expired-runs failed') + process.exit(1) + }) diff --git a/packages/server/src/billing/entitlements.ts b/packages/server/src/billing/entitlements.ts index d812128..7617d00 100644 --- a/packages/server/src/billing/entitlements.ts +++ b/packages/server/src/billing/entitlements.ts @@ -21,6 +21,10 @@ const LIMITS: Record> = { selfhost: { maxProjects: Number.POSITIVE_INFINITY, retentionDays: Number.POSITIVE_INFINITY, includedResults: Number.POSITIVE_INFINITY, alerts: true }, } +export function retentionDaysFor(tier: Tier): number { + return LIMITS[tier].retentionDays +} + function tierForProduct(productId: string | undefined): 'free' | 'team' | 'pro' { if (!productId) return 'free' diff --git a/packages/server/src/billing/retention.ts b/packages/server/src/billing/retention.ts new file mode 100644 index 0000000..6c82cb6 --- /dev/null +++ b/packages/server/src/billing/retention.ts @@ -0,0 +1,91 @@ +import { and, eq, inArray, lt, notInArray } from 'drizzle-orm' +import { db } from '../db' +import { artifact, project, run, subscription } from '../db/schemas/index' +import { cloud } from '../lib/env' +import { logger } from '../lib/logger' +import { storage } from '../lib/storage' +import { retentionDaysFor } from './entitlements' + +const BATCH = 500 +const DAY_MS = 24 * 60 * 60 * 1000 + +interface Scope { + includeUsers?: string[] + excludeUsers?: string[] +} + +function cutoff(now: Date, days: number): Date { + return new Date(now.getTime() - days * DAY_MS) +} + +async function purgeScope(before: Date, scope: Scope): Promise { + // A tier with no users on it: nothing to do. + if (scope.includeUsers && scope.includeUsers.length === 0) + return 0 + + let total = 0 + let fetched = BATCH + while (fetched === BATCH) { + const conds = [lt(run.startedAt, before)] + if (scope.includeUsers) + conds.push(inArray(project.userId, scope.includeUsers)) + if (scope.excludeUsers && scope.excludeUsers.length > 0) + conds.push(notInArray(project.userId, scope.excludeUsers)) + + const batch = await db + .select({ id: run.id }) + .from(run) + .innerJoin(project, eq(run.projectId, project.id)) + .where(and(...conds)) + .limit(BATCH) + + fetched = batch.length + if (fetched === 0) + break + + const ids = batch.map(b => b.id) + + // Blobs first: the run delete cascades the artifact rows, taking the keys with it. + const blobs = await db + .select({ key: artifact.storageKey }) + .from(artifact) + .where(inArray(artifact.runId, ids)) + for (const blob of blobs) { + try { + await storage.delete(blob.key) + } + catch (error) { + logger.error({ error, key: blob.key }, 'retention: blob delete failed') + } + } + + await db.delete(run).where(inArray(run.id, ids)) + total += fetched + } + + return total +} + +// Delete runs (cascading tests + artifacts + their blobs) past each tier's retention window. +export async function purgeExpiredRuns(now: Date): Promise<{ deleted: number }> { + if (!cloud) + return { deleted: 0 } // self-host keeps everything + + const subs = await db + .select({ userId: subscription.userId, tier: subscription.tier }) + .from(subscription) + + const teamUsers = subs.filter(s => s.tier === 'team').map(s => s.userId) + const proUsers = subs.filter(s => s.tier === 'pro').map(s => s.userId) + const paidUsers = subs + .filter(s => s.tier === 'team' || s.tier === 'pro' || s.tier === 'enterprise') + .map(s => s.userId) + + let deleted = 0 + // free = everyone not on a paid plan (covers no subscription + tier 'free'). + deleted += await purgeScope(cutoff(now, retentionDaysFor('free')), { excludeUsers: paidUsers }) + deleted += await purgeScope(cutoff(now, retentionDaysFor('team')), { includeUsers: teamUsers }) + deleted += await purgeScope(cutoff(now, retentionDaysFor('pro')), { includeUsers: proUsers }) + // enterprise retention is unlimited: never purged. + return { deleted } +} diff --git a/packages/server/src/lib/storage.ts b/packages/server/src/lib/storage.ts index 4a08b01..3e7bc94 100644 --- a/packages/server/src/lib/storage.ts +++ b/packages/server/src/lib/storage.ts @@ -1,13 +1,12 @@ import type { Buffer } from 'node:buffer' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { env } from './env' -// Local-FS object storage. Swap for S3/R2 (MinIO in self-host compose) later; -// callers only depend on this interface. export interface Storage { put: (key: string, body: Buffer | Uint8Array) => Promise url: (key: string) => string + delete: (key: string) => Promise } const root = resolve(env.STORAGE_DIR) @@ -21,4 +20,8 @@ export const storage: Storage = { url(key) { return `${env.BASE_URL}/artifacts/${key}` }, + async delete(key) { + // force ignores a missing file, so retention purge stays idempotent. + await rm(join(root, key), { force: true }) + }, } diff --git a/packages/web/src/pages/SettingsPage.vue b/packages/web/src/pages/SettingsPage.vue index 93df5e9..4bea1d7 100644 --- a/packages/web/src/pages/SettingsPage.vue +++ b/packages/web/src/pages/SettingsPage.vue @@ -225,6 +225,9 @@ function fmtDate(d: Date | string | null | undefined): string {

Monthly limit reached - upgrade to keep ingesting.

+

+ {{ billing.retentionDays != null ? `${billing.retentionDays}-day history` : 'Unlimited history' }} +

From e51f699b3b102845bfcec842e46f1249a784cc87 Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 23:00:34 +0200 Subject: [PATCH 09/10] feat: cli/reporter better error message --- packages/cli/src/kinora.ts | 6 +++++- packages/core/src/lib/ingest-client.ts | 28 ++++++++++++++++++++++++-- packages/reporter/src/index.ts | 7 +++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/kinora.ts b/packages/cli/src/kinora.ts index f8932aa..4d1e2ce 100644 --- a/packages/cli/src/kinora.ts +++ b/packages/cli/src/kinora.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' import { readFile } from 'node:fs/promises' import process from 'node:process' import { parseArgs } from 'node:util' +import { IngestError } from '@kinora/core' import { uploadReport } from './upload' const USAGE = `kinora - upload a Playwright json report to a kinora server @@ -92,6 +93,9 @@ async function main(): Promise { } main().catch((err) => { - console.error(err) + if (err instanceof IngestError) + console.error(`error: ${err.message}`) + else + console.error(err) process.exit(1) }) diff --git a/packages/core/src/lib/ingest-client.ts b/packages/core/src/lib/ingest-client.ts index ac6a366..e714bcf 100644 --- a/packages/core/src/lib/ingest-client.ts +++ b/packages/core/src/lib/ingest-client.ts @@ -48,6 +48,30 @@ export interface UploadArtifactInput { body: Uint8Array | Blob } +export class IngestError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.name = 'IngestError' + this.status = status + } +} + +// Prefer the server's JSON `error` field (human-readable, e.g. plan-limit messages). +async function toIngestError(res: Response, fallback: string): Promise { + const text = await res.text() + let message = text || fallback + try { + const body = JSON.parse(text) as { error?: unknown } + if (typeof body.error === 'string') + message = body.error + } + catch { + // non-JSON body: keep the raw text / fallback + } + return new IngestError(res.status, message) +} + export function createIngestClient(opts: IngestClientOptions) { const base = opts.baseUrl.replace(/\/+$/, '') const doFetch = opts.fetch ?? globalThis.fetch @@ -61,7 +85,7 @@ export function createIngestClient(opts: IngestClientOptions) { body: JSON.stringify(input), }) if (!res.ok) - throw new Error(`kinora ingest failed: ${res.status} ${await res.text()}`) + throw await toIngestError(res, `kinora ingest failed (${res.status})`) return ingestRunResultSchema.parse(await res.json()) }, @@ -79,7 +103,7 @@ export function createIngestClient(opts: IngestClientOptions) { body: form, }) if (!res.ok) - throw new Error(`kinora artifact upload failed: ${res.status} ${await res.text()}`) + throw await toIngestError(res, `kinora artifact upload failed (${res.status})`) return uploadArtifactResultSchema.parse(await res.json()) }, } diff --git a/packages/reporter/src/index.ts b/packages/reporter/src/index.ts index a7e9f33..9984053 100644 --- a/packages/reporter/src/index.ts +++ b/packages/reporter/src/index.ts @@ -2,7 +2,7 @@ import type { CiMeta, Counts, GitMeta, IngestRun, NormTest } from '@kinora/core' import type { FullConfig, FullResult, Reporter, Suite, TestCase } from '@playwright/test/reporter' import { readFile } from 'node:fs/promises' import process from 'node:process' -import { createIngestClient, isTraceAttachment, makeTestKey } from '@kinora/core' +import { createIngestClient, IngestError, isTraceAttachment, makeTestKey } from '@kinora/core' export interface KinoraReporterOptions { /** kinora server base URL. Defaults to env KINORA_URL. */ @@ -144,7 +144,10 @@ export default class KinoraReporter implements Reporter { console.log(`[kinora] uploaded ${res.tests} tests + ${traces} traces (run ${res.runId})`) } catch (err) { - console.error(`[kinora] upload failed:`, err instanceof Error ? err.message : err) + if (err instanceof IngestError && err.status === 402) + console.warn(`[kinora] ${err.message}`) + else + console.error(`[kinora] upload failed:`, err instanceof Error ? err.message : err) } } } From 2bd35e3a12f7748b4867719c41bd639840c7c98c Mon Sep 17 00:00:00 2001 From: Joris Gallot Date: Tue, 9 Jun 2026 23:13:32 +0200 Subject: [PATCH 10/10] test: add billing tests --- packages/core/test/ingest-client.test.ts | 44 ++++++++++++ packages/core/tsconfig.json | 2 +- packages/server/src/billing/retention.ts | 2 +- packages/server/test/helpers.ts | 2 +- packages/server/test/retention.test.ts | 68 ++++++++++++++++++ .../server/test/sync-customer-state.test.ts | 71 +++++++++++++++++++ packages/server/test/test-env.ts | 4 +- 7 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/ingest-client.test.ts create mode 100644 packages/server/test/retention.test.ts create mode 100644 packages/server/test/sync-customer-state.test.ts diff --git a/packages/core/test/ingest-client.test.ts b/packages/core/test/ingest-client.test.ts new file mode 100644 index 0000000..b4c0cea --- /dev/null +++ b/packages/core/test/ingest-client.test.ts @@ -0,0 +1,44 @@ +import type { IngestRun } from '../src/index' +import { describe, expect, it } from 'vitest' +import { createIngestClient, IngestError } from '../src/index' + +const PAYLOAD: IngestRun = { + project: { slug: 'web-app', name: 'web-app' }, + run: { + startedAt: '2026-01-01T00:00:00.000Z', + duration: 0, + counts: { total: 0, expected: 0, unexpected: 0, flaky: 0, skipped: 0 }, + }, + tests: [], +} + +function clientWith(response: Response) { + return createIngestClient({ baseUrl: 'http://test', token: 't', fetch: async () => response }) +} + +describe('createIngestClient.uploadRun', () => { + it('surfaces the server error message on 402', async () => { + const client = clientWith( + new Response(JSON.stringify({ error: 'Free plan limit reached. Upgrade.', limit: 2500 }), { status: 402 }), + ) + const err = await client.uploadRun(PAYLOAD).catch((e: unknown) => e) + expect(err).toBeInstanceOf(IngestError) + expect((err as IngestError).status).toBe(402) + expect((err as IngestError).message).toBe('Free plan limit reached. Upgrade.') + }) + + it('falls back to the raw body when it is not JSON', async () => { + const client = clientWith(new Response('upstream exploded', { status: 500 })) + const err = await client.uploadRun(PAYLOAD).catch((e: unknown) => e) + expect(err).toBeInstanceOf(IngestError) + expect((err as IngestError).status).toBe(500) + expect((err as IngestError).message).toBe('upstream exploded') + }) + + it('returns the parsed result on success', async () => { + const client = clientWith( + new Response(JSON.stringify({ projectId: 'p1', runId: 'r1', tests: 3 }), { status: 201 }), + ) + await expect(client.uploadRun(PAYLOAD)).resolves.toEqual({ projectId: 'p1', runId: 'r1', tests: 3 }) + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index de1c885..432043e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,5 +4,5 @@ "lib": ["ES2023"], "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/server/src/billing/retention.ts b/packages/server/src/billing/retention.ts index 6c82cb6..9bc0c06 100644 --- a/packages/server/src/billing/retention.ts +++ b/packages/server/src/billing/retention.ts @@ -18,7 +18,7 @@ function cutoff(now: Date, days: number): Date { return new Date(now.getTime() - days * DAY_MS) } -async function purgeScope(before: Date, scope: Scope): Promise { +export async function purgeScope(before: Date, scope: Scope): Promise { // A tier with no users on it: nothing to do. if (scope.includeUsers && scope.includeUsers.length === 0) return 0 diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts index 5dae497..46a77b8 100644 --- a/packages/server/test/helpers.ts +++ b/packages/server/test/helpers.ts @@ -7,7 +7,7 @@ import { db } from '../src/db' import { auth } from '../src/lib/auth' import { appRouter } from '../src/router/index' -const TABLES = ['artifact', 'test', 'run', 'project', 'apikey', 'verification', 'account', 'session', 'user'] +const TABLES = ['artifact', 'test', 'run', 'project', 'subscription', 'apikey', 'verification', 'account', 'session', 'user'] export async function resetDb(): Promise { await db.execute(sql.raw(`TRUNCATE ${TABLES.map(t => `"${t}"`).join(', ')} RESTART IDENTITY CASCADE`)) diff --git a/packages/server/test/retention.test.ts b/packages/server/test/retention.test.ts new file mode 100644 index 0000000..5e82b98 --- /dev/null +++ b/packages/server/test/retention.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it } from 'vitest' +import { purgeScope } from '../src/billing/retention' +import { db } from '../src/db' +import { project, run } from '../src/db/schemas/index' +import { createUser, resetDb } from './helpers' + +const DAY = 24 * 60 * 60 * 1000 + +beforeEach(resetDb) + +async function seedRun(userId: string, startedAt: Date): Promise { + const projectId = randomUUID() + await db.insert(project).values({ id: projectId, userId, slug: `s-${projectId}`, name: 'p' }) + const runId = randomUUID() + await db.insert(run).values({ + id: runId, + projectId, + startedAt, + duration: 0, + counts: { total: 0, expected: 0, unexpected: 0, flaky: 0, skipped: 0 }, + }) + return runId +} + +function exists(runId: string) { + return db.query.run.findFirst({ where: eq(run.id, runId), columns: { id: true } }) +} + +describe('purgeScope', () => { + it('deletes in-scope runs older than the cutoff, keeps newer and out-of-scope', async () => { + const a = await createUser('a@test.dev') + const b = await createUser('b@test.dev') + const now = Date.now() + const oldA = await seedRun(a.id, new Date(now - 100 * DAY)) + const recentA = await seedRun(a.id, new Date(now - 1 * DAY)) + const oldB = await seedRun(b.id, new Date(now - 100 * DAY)) + + await purgeScope(new Date(now - 30 * DAY), { includeUsers: [a.id] }) + + expect(await exists(oldA)).toBeFalsy() + expect(await exists(recentA)).toBeTruthy() + expect(await exists(oldB)).toBeTruthy() + }) + + it('spares the excluded users', async () => { + const a = await createUser('a@test.dev') + const b = await createUser('b@test.dev') + const now = Date.now() + const oldA = await seedRun(a.id, new Date(now - 100 * DAY)) + const oldB = await seedRun(b.id, new Date(now - 100 * DAY)) + + await purgeScope(new Date(now - 30 * DAY), { excludeUsers: [b.id] }) + + expect(await exists(oldA)).toBeFalsy() + expect(await exists(oldB)).toBeTruthy() + }) + + it('no-ops when includeUsers is empty', async () => { + const a = await createUser('a@test.dev') + const old = await seedRun(a.id, new Date(Date.now() - 100 * DAY)) + + await purgeScope(new Date(), { includeUsers: [] }) + + expect(await exists(old)).toBeTruthy() + }) +}) diff --git a/packages/server/test/sync-customer-state.test.ts b/packages/server/test/sync-customer-state.test.ts new file mode 100644 index 0000000..17112ca --- /dev/null +++ b/packages/server/test/sync-customer-state.test.ts @@ -0,0 +1,71 @@ +import { eq } from 'drizzle-orm' +import { beforeEach, describe, expect, it } from 'vitest' +import { syncCustomerState } from '../src/billing/entitlements' +import { db } from '../src/db' +import { subscription } from '../src/db/schemas/index' +import { env } from '../src/lib/env' +import { createUser, resetDb } from './helpers' + +const teamProductId = env.POLAR_PRODUCT_TEAM_ID +const proProductId = env.POLAR_PRODUCT_PRO_ID +if (!teamProductId || !proProductId) + throw new Error('sync-customer-state.test requires POLAR_PRODUCT_* in TEST_ENV') + +beforeEach(resetDb) + +function row(userId: string) { + return db.query.subscription.findFirst({ where: eq(subscription.userId, userId) }) +} + +describe('syncCustomerState', () => { + it('derives the tier from the active subscription product', async () => { + const user = await createUser() + await syncCustomerState({ + userId: user.id, + polarCustomerId: 'cus_1', + subscriptions: [{ productId: teamProductId, status: 'active', currentPeriodEnd: new Date('2030-01-01'), cancelAtPeriodEnd: false }], + }) + + const sub = await row(user.id) + expect(sub?.tier).toBe('team') + expect(sub?.status).toBe('active') + expect(sub?.polarCustomerId).toBe('cus_1') + expect(sub?.cancelAtPeriodEnd).toBe(false) + }) + + it('falls back to free when no subscription matches a known product', async () => { + const user = await createUser() + await syncCustomerState({ + userId: user.id, + polarCustomerId: 'cus_2', + subscriptions: [{ productId: 'unknown-product', status: 'active', currentPeriodEnd: null, cancelAtPeriodEnd: false }], + }) + + expect((await row(user.id))?.tier).toBe('free') + }) + + it('upserts a single row and updates it on the next sync', async () => { + const user = await createUser() + await syncCustomerState({ + userId: user.id, + polarCustomerId: 'cus_3', + subscriptions: [{ productId: teamProductId, status: 'trialing', currentPeriodEnd: null, cancelAtPeriodEnd: false }], + }) + await syncCustomerState({ + userId: user.id, + polarCustomerId: 'cus_3', + subscriptions: [{ productId: proProductId, status: 'active', currentPeriodEnd: null, cancelAtPeriodEnd: true }], + }) + + const all = await db.select().from(subscription).where(eq(subscription.userId, user.id)) + expect(all).toHaveLength(1) + expect(all[0]?.tier).toBe('pro') + expect(all[0]?.status).toBe('active') + expect(all[0]?.cancelAtPeriodEnd).toBe(true) + }) + + it('ignores a customer state with no external user id', async () => { + await syncCustomerState({ userId: null, polarCustomerId: 'cus_x', subscriptions: [] }) + expect(await db.select().from(subscription)).toHaveLength(0) + }) +}) diff --git a/packages/server/test/test-env.ts b/packages/server/test/test-env.ts index d006832..4964a4e 100644 --- a/packages/server/test/test-env.ts +++ b/packages/server/test/test-env.ts @@ -19,6 +19,6 @@ export const TEST_ENV: Record = { KINORA_CLOUD: 'false', POLAR_ACCESS_TOKEN: '', POLAR_WEBHOOK_SECRET: '', - POLAR_PRODUCT_TEAM_ID: '', - POLAR_PRODUCT_PRO_ID: '', + POLAR_PRODUCT_TEAM_ID: 'prod_team_test', + POLAR_PRODUCT_PRO_ID: 'prod_pro_test', }