From cdba80ac34b4e873865c341d5635f7e6058d600c Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:47:00 +0300 Subject: [PATCH 1/4] fix(migrate): order s2s messages alphabetically, describe the jti store as it is --- .contentrain/content/system/error-messages/en.json | 4 ++-- tests/unit/migrate-account-state.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 2236d9ff..b18179f7 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -246,12 +246,12 @@ "members.seat_limit_reached": "Team member limit reached ({limit}). Upgrade your plan to invite more members.", "migrate.claim_expired": "This link has expired. Open Studio again from your migration’s delivery page to get a fresh one.", "migrate.claim_invalid": "This link is not valid. Open Studio from your migration’s delivery page.", - "migrate.s2s_invalid": "This request from Migrate was not accepted.", - "migrate.s2s_replayed": "This request from Migrate was already used.", "migrate.claim_taken": "This migration’s Studio offer was already claimed by another Studio account. Sign in with that account, or contact support.", "migrate.grant_bound_elsewhere": "This Studio offer is already tied to another workspace.", "migrate.grant_not_found": "We couldn’t find this Studio offer on your account.", "migrate.grant_used": "This Studio offer has already been used — its included days started on a subscription for this workspace.", + "migrate.s2s_invalid": "This request from Migrate was not accepted.", + "migrate.s2s_replayed": "This request from Migrate was already used.", "migrate.unavailable": "Studio offers from Contentrain Migrate are not available on this Studio.", "migration.export_fetch_failed": "Could not fetch the comments export from its URL", "migration.export_too_large": "The comments export is too large to fetch; upload it in chunks instead", diff --git a/tests/unit/migrate-account-state.test.ts b/tests/unit/migrate-account-state.test.ts index ecd2eae4..9d6c15d2 100644 --- a/tests/unit/migrate-account-state.test.ts +++ b/tests/unit/migrate-account-state.test.ts @@ -77,7 +77,7 @@ describe('verifyMigrateS2sRequest', () => { expect(request).toMatchObject({ github_user_id: '99', plan: 'pro' }) }) - it('refuses a replay of the same jti, but the same jti for another purpose is another request', async () => { + it('refuses a replay of the same jti (the verifier keys the store by jti and purpose; the real table is keyed by jti alone, so it refuses across purposes too)', async () => { const token = await sign({}, { jti: 'once' }) expect(await reason(verify(token))).toBe('accepted') expect(await reason(verify(token))).toBe('replayed') From 234adc51030528148d72ed0e50804d40d7057874 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:54:51 +0300 Subject: [PATCH 2/4] feat(migrate): find the GitHub account through every identity, give a jti back on our own failure Supabase pair: public.migrate_user_id_by_identity reads auth.identities in one indexed lookup (a GitHub linked later is found). Managed pair: auth.identities for the plain-PG lineage, recorded at every OAuth sign-in, so a later Google sign-in no longer hides the GitHub id. account-state releases the jti when its own work fails, so Migrate's retry of the same request is taken. --- .../043_managed_auth_identities.sql | 28 ++++++++++++++++ scripts/verify-managed-schema.mjs | 1 + server/api/migrate/account-state.post.ts | 17 +++++++--- server/providers/database.ts | 3 ++ server/providers/managed-auth.ts | 24 +++++++++++--- .../providers/postgres-db/migrate-grants.ts | 5 +++ server/providers/postgres-db/types.ts | 9 ++++++ server/providers/supabase-auth.ts | 18 ++++------- .../providers/supabase-db/migrate-grants.ts | 6 ++++ supabase/migrations/042_migrate_s2s_jti.sql | 3 +- .../043_migrate_identity_lookup.sql | 32 +++++++++++++++++++ tests/contract/managed-auth.contract.test.ts | 21 ++++++++++++ .../contract/migrate-grants.contract.test.ts | 18 +++++++++++ tests/unit/migrate-account-state.test.ts | 9 ++++++ 14 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 postgres/migrations/043_managed_auth_identities.sql create mode 100644 supabase/migrations/043_migrate_identity_lookup.sql diff --git a/postgres/migrations/043_managed_auth_identities.sql b/postgres/migrations/043_managed_auth_identities.sql new file mode 100644 index 00000000..7f4b6529 --- /dev/null +++ b/postgres/migrations/043_managed_auth_identities.sql @@ -0,0 +1,28 @@ +-- 043 (plain-Postgres lineage only): every identity a user has signed in with. +-- +-- `auth.users.provider / provider_account_id` hold one slot, overwritten by the +-- latest OAuth sign-in: a user who signed in with GitHub and later with Google +-- loses the GitHub id there. Supabase keeps all of them in `auth.identities`; +-- this is the same table (provider, provider_id, user_id) for the managed +-- AuthProvider, so `public.migrate_user_id_by_identity` (supabase/migrations/ +-- 043_migrate_identity_lookup.sql) reads one shape on both pairs. +-- +-- Existing users are backfilled from the slot they have. Sorts before the +-- lookup function's migration, which reads this table. + +CREATE TABLE IF NOT EXISTS auth.identities ( + provider text NOT NULL, + provider_id text NOT NULL, + user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE, + last_sign_in_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (provider, provider_id) +); + +CREATE INDEX IF NOT EXISTS identities_user_id_idx ON auth.identities (user_id); + +INSERT INTO auth.identities (provider, provider_id, user_id, last_sign_in_at) +SELECT provider, provider_account_id, id, last_sign_in_at +FROM auth.users +WHERE provider IS NOT NULL AND provider_account_id IS NOT NULL +ON CONFLICT DO NOTHING; diff --git a/scripts/verify-managed-schema.mjs b/scripts/verify-managed-schema.mjs index bf6d50cd..98a37f97 100644 --- a/scripts/verify-managed-schema.mjs +++ b/scripts/verify-managed-schema.mjs @@ -54,6 +54,7 @@ async function main() { const expectedTables = [ 'auth.users', + 'auth.identities', 'auth.refresh_tokens', 'auth.one_time_tokens', 'auth.oauth_clients', diff --git a/server/api/migrate/account-state.post.ts b/server/api/migrate/account-state.post.ts index 8995b525..471b7caf 100644 --- a/server/api/migrate/account-state.post.ts +++ b/server/api/migrate/account-state.post.ts @@ -43,9 +43,16 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') }) } - const response = await resolveMigrateAccountState(request.github_user_id, request.plan) - // Fail closed on our own answer: Migrate prices from it. - if (!validateMigrateAccountStateResponse(response, { requested: request.plan }).ok) - throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') }) - return response + try { + const response = await resolveMigrateAccountState(request.github_user_id, request.plan) + // Fail closed on our own answer: Migrate prices from it. + if (!validateMigrateAccountStateResponse(response, { requested: request.plan }).ok) + throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') }) + return response + } + catch (err) { + // Our failure, not Migrate's: its retry of the same request must not be refused as a replay. + await useDatabaseProvider().releaseMigrateS2sJti(request.jti).catch(() => {}) + throw err + } }) diff --git a/server/providers/database.ts b/server/providers/database.ts index 765766d5..3cf46816 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1146,6 +1146,9 @@ export interface DatabaseProvider { */ claimMigrateS2sJti: (jti: string, purpose: string, expiresAt: Date) => Promise + /** Give a `jti` back after the work behind it failed on our side, so Migrate's retry of the same request is not refused. */ + releaseMigrateS2sJti: (jti: string) => Promise + /** * Workspaces `userId` owns, with the columns plan resolution reads. Admin * read for Migrate's account-state answer; Studio never lists them to the user this way. diff --git a/server/providers/managed-auth.ts b/server/providers/managed-auth.ts index 14b18a86..80b138c8 100644 --- a/server/providers/managed-auth.ts +++ b/server/providers/managed-auth.ts @@ -165,6 +165,7 @@ async function upsertOAuthUser(input: { }) .where('id', '=', existingId) .execute() + await recordIdentity(existingId, input.provider, input.providerAccountId, now) return (await loadUserById(existingId))! } @@ -181,9 +182,23 @@ async function upsertOAuthUser(input: { .returning('id') .executeTakeFirst() + await recordIdentity(inserted!.id, input.provider, input.providerAccountId, now) return (await loadUserById(inserted!.id))! } +/** + * Keep every identity a user signs in with (`auth.identities`, migration 043): + * the one-slot `provider` columns hold only the latest. An identity already + * attached to a user stays with them; only its sign-in time moves. + */ +async function recordIdentity(userId: string, provider: string, providerId: string, at: string): Promise { + await getDb() + .insertInto('auth.identities') + .values({ provider, provider_id: providerId, user_id: userId, last_sign_in_at: at }) + .onConflict(oc => oc.columns(['provider', 'provider_id']).doUpdateSet({ last_sign_in_at: at })) + .execute() +} + /** Find-or-create by email (magic link / invite). Inserts fire the bootstrap trigger. */ async function upsertEmailUser(email: string): Promise { const db = getDb() @@ -571,13 +586,14 @@ export function createManagedAuthProvider(): AuthProvider { }, async getUserByProviderAccount(provider, accountId) { - const row = await getDb() - .selectFrom('auth.users') - .select(['id', 'email', 'raw_user_meta_data', 'provider', 'provider_account_id']) + const identity = await getDb() + .selectFrom('auth.identities') + .select('user_id') .where('provider', '=', provider) - .where('provider_account_id', '=', accountId) + .where('provider_id', '=', accountId) .executeTakeFirst() + const row = identity ? await loadUserById(identity.user_id) : undefined return row ? toAuthUser(row) : null }, diff --git a/server/providers/postgres-db/migrate-grants.ts b/server/providers/postgres-db/migrate-grants.ts index 8d651e2b..0edbbb13 100644 --- a/server/providers/postgres-db/migrate-grants.ts +++ b/server/providers/postgres-db/migrate-grants.ts @@ -15,6 +15,7 @@ type MigrateGrantMethods = Pick< | 'markMigrateGrantRedeemed' | 'getMigrateGrantOrigin' | 'claimMigrateS2sJti' + | 'releaseMigrateS2sJti' | 'listOwnedWorkspacesAdmin' | 'saveMigrateCommentsExport' | 'getMigrateCommentsExport' @@ -63,6 +64,10 @@ export function migrateGrantMethods(): MigrateGrantMethods { return !!inserted }, + async releaseMigrateS2sJti(jti) { + await getAdmin().deleteFrom('migrate_s2s_jti').where('jti', '=', jti).execute() + }, + async listOwnedWorkspacesAdmin(userId) { const rows = await getAdmin() .selectFrom('workspaces') diff --git a/server/providers/postgres-db/types.ts b/server/providers/postgres-db/types.ts index 3e15dd0e..5797e754 100644 --- a/server/providers/postgres-db/types.ts +++ b/server/providers/postgres-db/types.ts @@ -581,6 +581,14 @@ export interface AuthUsersTable { updated_at: Generated } +export interface AuthIdentitiesTable { + provider: string + provider_id: string + user_id: string + last_sign_in_at: string | null + created_at: Generated +} + export interface AuthRefreshTokensTable { id: Generated user_id: string @@ -671,6 +679,7 @@ export interface AuthOauthRefreshTokensTable { export interface StudioDatabase { 'auth.users': AuthUsersTable + 'auth.identities': AuthIdentitiesTable 'auth.refresh_tokens': AuthRefreshTokensTable 'auth.one_time_tokens': AuthOneTimeTokensTable 'auth.oauth_clients': AuthOauthClientsTable diff --git a/server/providers/supabase-auth.ts b/server/providers/supabase-auth.ts index fb05ae4e..4562f72d 100644 --- a/server/providers/supabase-auth.ts +++ b/server/providers/supabase-auth.ts @@ -257,17 +257,13 @@ export function createSupabaseAuthProvider(): AuthProvider { async getUserByProviderAccount(provider: 'github' | 'google', accountId: string): Promise { const admin = createSupabaseAdminClient() - let page = 1 - const perPage = 1000 - while (true) { - const { data, error } = await admin.auth.admin.listUsers({ page, perPage }) - if (error) throw error - const user = data?.users?.find(u => u.app_metadata?.provider === provider && String(u.user_metadata?.provider_id ?? '') === accountId) - if (user) return mapSupabaseUser(user) - if (!data?.users || data.users.length < perPage) break - page++ - } - return null + // `auth.identities` holds every identity the user has, GitHub linked later included (migration 043). + const { data: userId, error } = await admin.rpc('migrate_user_id_by_identity', { p_provider: provider, p_account_id: accountId }) + if (error) throw error + if (!userId) return null + const { data, error: userError } = await admin.auth.admin.getUserById(userId as string) + if (userError) throw userError + return data?.user ? mapSupabaseUser(data.user) : null }, async deleteUser(userId: string): Promise { diff --git a/server/providers/supabase-db/migrate-grants.ts b/server/providers/supabase-db/migrate-grants.ts index e6856d1b..cf4378f0 100644 --- a/server/providers/supabase-db/migrate-grants.ts +++ b/server/providers/supabase-db/migrate-grants.ts @@ -15,6 +15,7 @@ type MigrateGrantMethods = Pick< | 'markMigrateGrantRedeemed' | 'getMigrateGrantOrigin' | 'claimMigrateS2sJti' + | 'releaseMigrateS2sJti' | 'listOwnedWorkspacesAdmin' | 'saveMigrateCommentsExport' | 'getMigrateCommentsExport' @@ -78,6 +79,11 @@ export function migrateGrantMethods(): MigrateGrantMethods { return (data?.length ?? 0) > 0 }, + async releaseMigrateS2sJti(jti) { + const { error } = await getAdmin().from('migrate_s2s_jti').delete().eq('jti', jti) + if (error) fail(error.message) + }, + async listOwnedWorkspacesAdmin(userId) { const { data, error } = await getAdmin() .from('workspaces') diff --git a/supabase/migrations/042_migrate_s2s_jti.sql b/supabase/migrations/042_migrate_s2s_jti.sql index 8ce66610..bf524c2b 100644 --- a/supabase/migrations/042_migrate_s2s_jti.sql +++ b/supabase/migrations/042_migrate_s2s_jti.sql @@ -4,7 +4,8 @@ -- grant status/revoke) with a one-use `jti`. A claim is single-use per order, -- enforced by `migrate_grants`; these calls have no such row, so the `jti` is -- remembered here until the token could no longer verify, and a repeat is --- refused. `purpose` keeps one endpoint's token from being replayed on another. +-- refused. The `jti` is the key, so a token cannot be replayed on another +-- endpoint either; `purpose` only records which endpoint took it (support). -- -- Service-role only: RLS on, no policies, like `migrate_grants`. diff --git a/supabase/migrations/043_migrate_identity_lookup.sql b/supabase/migrations/043_migrate_identity_lookup.sql new file mode 100644 index 00000000..de1a5997 --- /dev/null +++ b/supabase/migrations/043_migrate_identity_lookup.sql @@ -0,0 +1,32 @@ +-- 043: find a Studio user by the id an OAuth provider gave them, in one indexed read. +-- +-- Migrate's account-state call (S1) knows only a GitHub user id. On the Supabase +-- pair that id lives in `auth.identities` (provider + provider_id), which holds +-- every identity a user has — including a GitHub account linked after they +-- signed up another way. The admin API can only list users page by page, so the +-- lookup is a function the service role calls. On the plain-Postgres pair +-- `auth.users` carries the identity itself and needs no function. +-- +-- plpgsql, not sql: `auth.identities` exists on Supabase only, and the plain-PG +-- lineage (auth shim) must still create this function without it. +-- Service-role only. + +CREATE OR REPLACE FUNCTION public.migrate_user_id_by_identity(p_provider text, p_account_id text) +RETURNS uuid +LANGUAGE plpgsql SECURITY DEFINER STABLE +SET search_path = public, auth +AS $$ +DECLARE + v_user uuid; +BEGIN + SELECT user_id INTO v_user + FROM auth.identities + WHERE provider = p_provider AND provider_id = p_account_id + ORDER BY last_sign_in_at DESC NULLS LAST + LIMIT 1; + RETURN v_user; +END; +$$; + +REVOKE ALL ON FUNCTION public.migrate_user_id_by_identity(text, text) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.migrate_user_id_by_identity(text, text) TO service_role; diff --git a/tests/contract/managed-auth.contract.test.ts b/tests/contract/managed-auth.contract.test.ts index ba757159..69a8cead 100644 --- a/tests/contract/managed-auth.contract.test.ts +++ b/tests/contract/managed-auth.contract.test.ts @@ -115,6 +115,27 @@ describe('managed-auth provider (contract)', () => { expect(viaGoogle.user.provider).toBe('google') }) + it('finds a user by any identity they signed in with, GitHub linked later or overwritten by another provider', async () => { + const email = `identity-${randomUUID()}@managed.test` + const githubId = `gh-${randomUUID()}` + const googleId = `g-${randomUUID()}` + + // Signed up by magic link first; GitHub is linked later. + const { userId } = await auth.inviteUserByEmail(email) + cleanupUserIds.push(userId) + expect(await auth.getUserByProviderAccount('github', githubId)).toBeNull() + + await completeOAuthSignIn({ provider: 'github', providerAccountId: githubId, email, name: null, userName: null, avatarUrl: null }) + expect((await auth.getUserByProviderAccount('github', githubId))!.id).toBe(userId) + + // A later Google sign-in overwrites the one-slot columns, not the GitHub identity. + await completeOAuthSignIn({ provider: 'google', providerAccountId: googleId, email, name: null, userName: null, avatarUrl: null }) + expect((await auth.getUserById(userId))!.provider).toBe('google') + expect((await auth.getUserByProviderAccount('github', githubId))!.id).toBe(userId) + expect((await auth.getUserByProviderAccount('google', googleId))!.id).toBe(userId) + expect(await auth.getUserByProviderAccount('google', githubId)).toBeNull() + }) + it('refreshSession rotates within a family and revokes the family on replay', async () => { const session = await completeOAuthSignIn({ provider: 'github', diff --git a/tests/contract/migrate-grants.contract.test.ts b/tests/contract/migrate-grants.contract.test.ts index 57b35337..16ee4de8 100644 --- a/tests/contract/migrate-grants.contract.test.ts +++ b/tests/contract/migrate-grants.contract.test.ts @@ -186,6 +186,24 @@ describe('postgres-db migrate-grants (contract)', () => { expect(await methods.claimMigrateS2sJti(old, 'account-state', future)).toBe(true) }) + it('gives a jti back after our own failure, so the same request can be taken again', async () => { + const jti = `${orderId}-s2s-release` + const future = new Date(Date.now() + 600_000) + expect(await methods.claimMigrateS2sJti(jti, 'account-state', future)).toBe(true) + await methods.releaseMigrateS2sJti(jti) + expect(await methods.claimMigrateS2sJti(jti, 'account-state', future)).toBe(true) + expect(await methods.claimMigrateS2sJti(jti, 'account-state', future)).toBe(false) + }) + + it('finds the user behind a GitHub id through the lookup function the Supabase pair calls (migration 043)', async () => { + const githubId = `gh-${Date.now()}` + await sql`INSERT INTO auth.identities (provider, provider_id, user_id) VALUES ('github', ${githubId}, ${owner.userId})`.execute(getDb()) + const found = await sql<{ id: string | null }>`SELECT public.migrate_user_id_by_identity('github', ${githubId}) AS id`.execute(getDb()) + expect(found.rows[0]?.id).toBe(owner.userId) + const none = await sql<{ id: string | null }>`SELECT public.migrate_user_id_by_identity('github', 'no-such-id') AS id`.execute(getDb()) + expect(none.rows[0]?.id).toBeNull() + }) + it('lists the workspaces a user owns, and no one else\'s', async () => { const owned = await methods.listOwnedWorkspacesAdmin(owner.userId) expect(owned.map(w => w.id)).toContain(owner.workspaceId) diff --git a/tests/unit/migrate-account-state.test.ts b/tests/unit/migrate-account-state.test.ts index 9d6c15d2..885e9b9d 100644 --- a/tests/unit/migrate-account-state.test.ts +++ b/tests/unit/migrate-account-state.test.ts @@ -122,6 +122,7 @@ describe('POST /api/migrate/account-state', () => { const seen = new Set() db = { claimMigrateS2sJti: vi.fn(async (jti: string) => (seen.has(jti) ? false : (seen.add(jti), true))), + releaseMigrateS2sJti: vi.fn(async (jti: string) => { seen.delete(jti) }), listOwnedWorkspacesAdmin: vi.fn().mockResolvedValue([]), getActivePaymentAccount: vi.fn().mockResolvedValue(null), } @@ -170,6 +171,14 @@ describe('POST /api/migrate/account-state', () => { expect(await ask('pro')).toEqual({ state: 'too_small', plan: 'pro', year1_cents: 32000, current_plan: 'starter' }) }) + it('gives the jti back when our own work fails, so Migrate\'s retry of the same request is taken', async () => { + const token = await sign({ plan: 'pro' }) + auth.getUserByProviderAccount.mockRejectedValueOnce(new Error('db down')) + await expect(call({ token })).rejects.toThrow('db down') + expect(db.releaseMigrateS2sJti).toHaveBeenCalledTimes(1) + expect(await call({ token })).toMatchObject({ state: 'none', plan: 'pro' }) + }) + it('takes the highest running plan across the user\'s workspaces', async () => { db.listOwnedWorkspacesAdmin.mockResolvedValue([{ id: 'a', type: 'secondary', plan: 'starter' }, { id: 'b', type: 'secondary', plan: 'pro' }]) db.getActivePaymentAccount.mockImplementation(async (id: string) => account(id === 'a' ? 'starter' : 'pro')) From b24513dcfd486d6f4746c2efe7e9ae08fef9dabd Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:42:56 +0300 Subject: [PATCH 3/4] feat(migrate): provision a Migrate with Studio bundle behind one Polar checkout POST /api/migrate/provision takes a signed claim v2, finds or creates the Studio account by GitHub id, records one bundle grant per order and opens one Polar checkout at the quoted total. The webhook then moves the subscription to the yearly list product at the next period; a scheduled reconciler retries a failed move and logs an alarm 30 days before renewal. Migration 044 makes trial days and repo optional for bundle grants. --- .../content/system/error-messages/en.json | 6 + .env.example | 7 + docs/PAYMENT_PROVIDERS.md | 26 ++ nuxt.config.ts | 6 + package.json | 2 +- pnpm-lock.yaml | 10 +- server/api/billing/webhook/[provider].post.ts | 4 +- server/api/migrate/claim.post.ts | 3 + .../migrate/grants/[grantId]/checkout.post.ts | 3 + server/api/migrate/provision.post.ts | 55 ++++ server/plugins/migrate-bundle-reconciler.ts | 35 ++ server/providers/auth.ts | 22 ++ server/providers/database.ts | 32 +- server/providers/managed-auth.ts | 42 +++ server/providers/payment/plugins/polar.ts | 81 ++++- server/providers/payment/plugins/stripe.ts | 8 + server/providers/payment/types.ts | 39 +++ .../providers/postgres-db/migrate-grants.ts | 76 ++++- server/providers/postgres-db/types.ts | 15 +- server/providers/supabase-auth.ts | 19 ++ .../providers/supabase-db/migrate-grants.ts | 54 ++- server/utils/migrate-bundle-subscription.ts | 91 ++++++ server/utils/migrate-grant.ts | 12 +- server/utils/migrate-provision.ts | 153 +++++++++ .../migrations/044_migrate_bundle_grants.sql | 41 +++ tests/contract/managed-auth.contract.test.ts | 40 +++ .../contract/migrate-grants.contract.test.ts | 58 ++++ .../billing-webhook.integration.test.ts | 8 + tests/unit/migrate-bundle-polar.test.ts | 138 ++++++++ .../unit/migrate-bundle-subscription.test.ts | 109 +++++++ tests/unit/migrate-grant-routes.test.ts | 12 + tests/unit/migrate-provision.test.ts | 308 ++++++++++++++++++ 32 files changed, 1488 insertions(+), 27 deletions(-) create mode 100644 server/api/migrate/provision.post.ts create mode 100644 server/plugins/migrate-bundle-reconciler.ts create mode 100644 server/utils/migrate-bundle-subscription.ts create mode 100644 server/utils/migrate-provision.ts create mode 100644 supabase/migrations/044_migrate_bundle_grants.sql create mode 100644 tests/unit/migrate-bundle-polar.test.ts create mode 100644 tests/unit/migrate-bundle-subscription.test.ts create mode 100644 tests/unit/migrate-provision.test.ts diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index b18179f7..73350324 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -244,12 +244,18 @@ "members.resend_failed": "Failed to send invitation email. Please try again.", "members.resend_rate_limited": "Too many resend attempts. Please wait before trying again.", "members.seat_limit_reached": "Team member limit reached ({limit}). Upgrade your plan to invite more members.", + "migrate.bundle_state_unsupported": "Studio cannot add this order to the existing plan yet. Contact support and we will finish it for you.", "migrate.claim_expired": "This link has expired. Open Studio again from your migration’s delivery page to get a fresh one.", "migrate.claim_invalid": "This link is not valid. Open Studio from your migration’s delivery page.", "migrate.claim_taken": "This migration’s Studio offer was already claimed by another Studio account. Sign in with that account, or contact support.", + "migrate.email_unverified": "The email on this order is not verified, so Studio cannot create or link an account for it.", "migrate.grant_bound_elsewhere": "This Studio offer is already tied to another workspace.", + "migrate.grant_bundle": "This Studio year is part of your Migrate order. It was paid at checkout and has no included trial to claim.", "migrate.grant_not_found": "We couldn’t find this Studio offer on your account.", "migrate.grant_used": "This Studio offer has already been used — its included days started on a subscription for this workspace.", + "migrate.identity_conflict": "This GitHub account cannot be linked to the Studio account that owns this email.", + "migrate.quote_changed": "The Studio price changed since the quote. Reload the quote and check out again.", + "migrate.return_url_not_allowed": "The return address is not on this Studio's Migrate allowlist.", "migrate.s2s_invalid": "This request from Migrate was not accepted.", "migrate.s2s_replayed": "This request from Migrate was already used.", "migrate.unavailable": "Studio offers from Contentrain Migrate are not available on this Studio.", diff --git a/.env.example b/.env.example index f4fd44bd..bde51ca9 100644 --- a/.env.example +++ b/.env.example @@ -175,6 +175,13 @@ NUXT_POLAR_ACCESS_TOKEN=polar_oat_your-organization-access-token NUXT_POLAR_WEBHOOK_SECRET=your-polar-webhook-signing-secret NUXT_POLAR_STARTER_PRODUCT_ID=uuid-of-starter-product NUXT_POLAR_PRO_PRODUCT_ID=uuid-of-pro-product +# Optional: the "Migrate with Studio" bundle (POST /api/migrate/provision). Per plan, the +# product the ad-hoc priced first invoice is sold on and the yearly list product the +# subscription moves to for renewal. Empty = the bundle is off (provision answers 502). +NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID= +NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID= +NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID= +NUXT_POLAR_PRO_YEARLY_PRODUCT_ID= # 'sandbox' for Polar sandbox environment; 'production' for live. NUXT_POLAR_SERVER=sandbox diff --git a/docs/PAYMENT_PROVIDERS.md b/docs/PAYMENT_PROVIDERS.md index a91c8536..1fa79a48 100644 --- a/docs/PAYMENT_PROVIDERS.md +++ b/docs/PAYMENT_PROVIDERS.md @@ -104,6 +104,32 @@ NUXT_POLAR_SERVER=sandbox # or production Note: `NUXT_PUBLIC_BILLING_ENABLED` is derived automatically at boot by the `server/plugins/00.billing-flag.ts` Nitro plugin — when the Polar access token resolves the plugin registry's `isConfigured()` gate, the public flag flips to `true`. You only need to set it explicitly to override (e.g. staging with Polar configured but checkout intentionally hidden). +## Migrate with Studio bundle (managed, Polar only) + +Migrate can sell its own fee plus Studio year 1 as one order. Migrate calls +`POST /api/migrate/provision` (signed claim v2, same key as the claim link); +Studio finds or creates the account behind the GitHub user, records one grant +per order and opens one Polar checkout whose first invoice is the quoted total +(an ad-hoc fixed price on the plan's **bundle product**). Studio never prices +on this path: it refuses a quote it does not agree with (`migrate.quote_changed`) +and the states it cannot sell this way yet (an account that already has a plan, +a workspace with a live subscription). + +When `subscription.created` arrives the webhook moves the subscription to the +plan's **yearly list product** with `proration_behavior=next_period`, so the +renewal is the list price. Polar keeps an ad-hoc price on a subscription for +good, so a move that failed is retried every 6 hours (`migrate-bundle-reconciler` +plugin) and an error-level `[migrate-bundle] ALARM` line is logged for any +subscription still unmoved 30 days before its renewal. Point a log alert at it. + +```bash +NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID=… # sold with an ad-hoc price per checkout +NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID=… +NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID=… # the subscription's list product from the next period +NUXT_POLAR_PRO_YEARLY_PRODUCT_ID=… +NUXT_MIGRATE_ORIGINS=https://migrate.contentrain.io # the only hosts a return_url may name +``` + ## Studio included with a Migrate order (managed) A paid Contentrain Migrate order can include N days of a Studio plan. Migrate diff --git a/nuxt.config.ts b/nuxt.config.ts index 464b6249..3b16d0d2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -117,6 +117,12 @@ export default defineNuxtConfig({ starterProductId: '', // NUXT_POLAR_STARTER_PRODUCT_ID proProductId: '', // NUXT_POLAR_PRO_PRODUCT_ID server: 'production', // NUXT_POLAR_SERVER — 'sandbox' | 'production' + // "Migrate with Studio" bundle: the product the ad-hoc priced first invoice is sold on, and the + // yearly list product the subscription moves to for renewal. Empty = the bundle is off. + starterBundleProductId: '', // NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID + proBundleProductId: '', // NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID + starterYearlyProductId: '', // NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID + proYearlyProductId: '', // NUXT_POLAR_PRO_YEARLY_PRODUCT_ID }, migrate: { // NUXT_MIGRATE_CLAIM_PUBLIC_KEY — Contentrain Migrate's Ed25519 public key diff --git a/package.json b/package.json index 54fa3706..bf8b5104 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@aws-sdk/client-s3": "^3.1076.0", "@contentrain/mcp": "3.9.0", "@contentrain/query": "7.4.0", - "@contentrain/types": "1.40.0", + "@contentrain/types": "1.43.0", "@gitbeaker/rest": "^43.8.0", "@nuxt/eslint": "1.16.0", "@nuxt/image": "2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89b9e83b..285c59a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 7.4.0 version: 7.4.0 '@contentrain/types': - specifier: 1.40.0 - version: 1.40.0 + specifier: 1.43.0 + version: 1.43.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -713,8 +713,8 @@ packages: '@contentrain/types@1.30.0': resolution: {integrity: sha512-inJhFqAY25wIw4NvpqDXOn24PRbVEh43s6GGFQSRyBbbmGiWu+xD67D2UEqaEllaFNLSVQ0j2DpOjDj+P6ezQA==} - '@contentrain/types@1.40.0': - resolution: {integrity: sha512-T/oUpkkVvBg6Ad9YcenvPAOh7IkcMmPWsmzoHimAcwUjCB799WNokwH3/kB6JGMPDCrtpXyS7yPZetdGzbIo7g==} + '@contentrain/types@1.43.0': + resolution: {integrity: sha512-f2PgiPFpowsxeaOfTwbhndqim0A4bkzjv7HsXUPG6cgKEn7xmsj0YWFwAHu6pxOLT5DHLsEEIZtWUBYb5FhEvw==} '@conventional-changelog/git-client@3.1.2': resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} @@ -7938,7 +7938,7 @@ snapshots: '@contentrain/types@1.30.0': {} - '@contentrain/types@1.40.0': {} + '@contentrain/types@1.43.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/api/billing/webhook/[provider].post.ts b/server/api/billing/webhook/[provider].post.ts index 2e58b2f6..bc2a7871 100644 --- a/server/api/billing/webhook/[provider].post.ts +++ b/server/api/billing/webhook/[provider].post.ts @@ -320,7 +320,7 @@ export default defineEventHandler(async (event) => { // grant up: no second included trial after cancel-and-resubscribe. // Idempotent — whichever of created/updated arrives first marks it. if (result.migrateGrantId) { - await db.markMigrateGrantRedeemed(result.migrateGrantId, result.subscriptionId ?? null) + await redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null) } // First 'trialing' observation consumes the workspace's one-time // trial, so a later re-checkout (after cancel/expiry) gets a paid @@ -429,7 +429,7 @@ export default defineEventHandler(async (event) => { // grant up: no second included trial after cancel-and-resubscribe. // Idempotent — whichever of created/updated arrives first marks it. if (result.migrateGrantId) { - await db.markMigrateGrantRedeemed(result.migrateGrantId, result.subscriptionId ?? null) + await redeemMigrateGrant(provider, result.migrateGrantId, result.subscriptionId ?? null) } const workspaceUpdate: Record = {} diff --git a/server/api/migrate/claim.post.ts b/server/api/migrate/claim.post.ts index 5b3730f1..5dc8e3b6 100644 --- a/server/api/migrate/claim.post.ts +++ b/server/api/migrate/claim.post.ts @@ -58,6 +58,9 @@ export default defineEventHandler(async (event) => { if (grant.user_id !== session.user.id) throw createError({ statusCode: 409, message: errorMessage('migrate.claim_taken') }) + // The order was bought as a bundle: its Studio year is on the order, there is no trial to claim. + if (grant.kind === 'bundle') + throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bundle') }) const existing = await useDatabaseProvider().getMigrateCommentsExportState(grant.id as string) // Not awaited: a slow or failing export never holds the claim up. It never throws. diff --git a/server/api/migrate/grants/[grantId]/checkout.post.ts b/server/api/migrate/grants/[grantId]/checkout.post.ts index 4f1be234..9baadd5f 100644 --- a/server/api/migrate/grants/[grantId]/checkout.post.ts +++ b/server/api/migrate/grants/[grantId]/checkout.post.ts @@ -45,6 +45,9 @@ export default defineEventHandler(async (event) => { if (grant.redeemed_at) throw createError({ statusCode: 409, message: errorMessage('migrate.grant_used') }) + // A bundle grant is paid through Migrate's checkout, never opened as an included trial. + if (grant.kind === 'bundle') + throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bundle') }) if (grant.bound_at && grant.workspace_id !== workspaceId) throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bound_elsewhere') }) diff --git a/server/api/migrate/provision.post.ts b/server/api/migrate/provision.post.ts new file mode 100644 index 00000000..f9a7007b --- /dev/null +++ b/server/api/migrate/provision.post.ts @@ -0,0 +1,55 @@ +/** + * POST /api/migrate/provision + * + * Migrate asks, server to server, for the Studio side of a "Migrate with + * Studio" bundle: the customer's Studio account and grant, and the one Polar + * checkout that charges the whole quote. Body `{ token }`: a claim v2 signed + * with Migrate's key (`MigrateStudioClaimV2`, `@contentrain/types`), single-use + * by `jti`. Not a user surface: no session. See `provisionMigrateBundle`. + */ +import { validateMigrateStudioClaimV2 } from '@contentrain/types' +import { migrateClaimPublicKey } from '../../utils/migrate-grant' +import { provisionMigrateBundle } from '../../utils/migrate-provision' +import { MigrateS2sError, verifyMigrateS2sRequest } from '../../utils/migrate-s2s' + +export default defineEventHandler(async (event) => { + const publicKey = migrateClaimPublicKey() + if (!publicKey) throw createError({ statusCode: 404, message: errorMessage('migrate.unavailable') }) + + const body = await readBody<{ token?: unknown }>(event) + if (typeof body?.token !== 'string' || body.token.length === 0 || body.token.length > 8192) + throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') }) + + let claim + try { + claim = await verifyMigrateS2sRequest( + body.token, + publicKey, + 'provision', + (payload, now) => { + const checked = validateMigrateStudioClaimV2(payload, { now }) + return checked.ok ? { ok: true, value: checked.claim } : checked + }, + (jti, purpose, expiresAt) => useDatabaseProvider().claimMigrateS2sJti(jti, purpose, expiresAt), + ) + } + catch (err) { + if (err instanceof MigrateS2sError) { + if (err.reason === 'expired') throw createError({ statusCode: 410, message: errorMessage('migrate.claim_expired') }) + if (err.reason === 'replayed') throw createError({ statusCode: 409, message: errorMessage('migrate.s2s_replayed') }) + } + throw createError({ statusCode: 400, message: errorMessage('migrate.s2s_invalid') }) + } + + try { + return await provisionMigrateBundle(claim) + } + catch (err) { + // Only our own failures give the `jti` back, so Migrate's retry of the same request is not + // refused as a replay. A refusal (4xx: quote changed, state unsupported, ...) is an answer, + // and a failure after a checkout exists is persisted on the grant, so a retry finds it. + const status = (err as { statusCode?: number }).statusCode + if (!status || status >= 500) await useDatabaseProvider().releaseMigrateS2sJti(claim.jti).catch(() => {}) + throw err + } +}) diff --git a/server/plugins/migrate-bundle-reconciler.ts b/server/plugins/migrate-bundle-reconciler.ts new file mode 100644 index 00000000..d08c53a3 --- /dev/null +++ b/server/plugins/migrate-bundle-reconciler.ts @@ -0,0 +1,35 @@ +/** + * Migrate bundle reconciler — Nitro plugin. + * + * Every 6 hours, retries the move of bundle subscriptions to the yearly list + * product that the billing webhook could not complete, and raises the alarm + * for those within 30 days of renewal (`reconcileMigrateBundles`). Does nothing + * when the deployment has no payment provider or no bundle grants. + */ +import { reconcileMigrateBundles } from '../utils/migrate-bundle-subscription' +import { useDatabaseProvider, usePaymentProvider } from '../utils/providers' + +const INTERVAL_MS = 6 * 60 * 60 * 1000 + +export default defineNitroPlugin((nitroApp) => { + setTimeout(() => runReconcile().catch(logFailure), 60_000) + const interval = setInterval(() => { + runReconcile().catch(logFailure) + }, INTERVAL_MS) + nitroApp.hooks.hook('close', () => clearInterval(interval)) +}) + +function logFailure(err: unknown) { + // eslint-disable-next-line no-console -- scheduled background job; failure must surface somewhere + console.error('[migrate-bundle] Scheduled reconcile failed:', err) +} + +async function runReconcile(): Promise { + const payment = usePaymentProvider() + if (!payment) return + // Cheap guard first: no bundle grant waiting, no provider call. + if ((await useDatabaseProvider().listPendingMigrateBundles(1)).length === 0) return + const summary = await reconcileMigrateBundles(payment) + // eslint-disable-next-line no-console -- scheduled job summary + console.info('[migrate-bundle] reconcile', summary) +} diff --git a/server/providers/auth.ts b/server/providers/auth.ts index a9dd480e..12f0ae8f 100644 --- a/server/providers/auth.ts +++ b/server/providers/auth.ts @@ -132,6 +132,21 @@ export interface AuthProvider { */ getUserByProviderAccount: (provider: 'github' | 'google', accountId: string) => Promise + /** + * Find the user behind an OAuth account, or create one for it without a + * sign-in (a Migrate customer who pays before ever opening Studio). The + * email must be one the provider verified: it links an existing user with + * that email (their stored profile is left as it is), or names the new + * one. Creating fires the same bootstrap as a first sign-in (profile and + * personal workspace). Throws `IdentityConflictError` when the email's + * user already has a different account of that provider. + */ + ensureUserForProviderAccount: (input: { + provider: 'github' + accountId: string + email: string + }) => Promise + /** * Delete a user account permanently. * Cascades to profiles, workspaces (owned), memberships, etc. @@ -146,3 +161,10 @@ export interface AuthProvider { */ revokeSession?: (refreshToken: string) => Promise } + +/** The email's user already signed in with another account of the same provider. */ +export class IdentityConflictError extends Error { + constructor() { + super('Email belongs to a user with a different provider account') + } +} diff --git a/server/providers/database.ts b/server/providers/database.ts index 3cf46816..daa7daf7 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -1103,17 +1103,43 @@ export interface DatabaseProvider { claimJti: string userId: string plan: 'starter' | 'pro' - trialDays: number - repoOwner: string - repoName: string + /** Absent for a bundle grant: it opens no included trial (migration 044). */ + trialDays?: number | null + /** Absent for a bundle grant until the delivery repository exists. */ + repoOwner?: string | null + repoName?: string | null email: string /** * The migrated site, as the claim signed it (migration 039). A grant * recorded without one takes it from a later claim for the same order. */ origin?: string | null + /** `bundle` for a grant opened by a provision; defaults to `trial`. */ + kind?: 'trial' | 'bundle' }) => Promise<{ grant: DatabaseRow, created: boolean }> + /** A grant by id (admin read; the billing webhook and the reconciler have no user). */ + getMigrateGrantById: (grantId: string) => Promise + + /** + * Remember the Polar checkout a bundle grant opened and the list product its + * subscription must move to (migration 044). Overwrites an earlier, + * expired checkout. + */ + saveMigrateGrantCheckout: (grantId: string, input: { + checkoutId: string + checkoutUrl: string + checkoutExpiresAt: string + amountCents: number + targetProductId: string + }) => Promise + + /** The subscription moved to the list product (or was on it already): the reconciler stops watching it. */ + markMigrateBundleApplied: (grantId: string) => Promise + + /** Bundle grants whose subscription still has to move to the list product, oldest first. */ + listPendingMigrateBundles: (limit: number) => Promise + /** A grant, only if `userId` owns it. */ getMigrateGrantForUser: (grantId: string, userId: string) => Promise diff --git a/server/providers/managed-auth.ts b/server/providers/managed-auth.ts index 80b138c8..c121304d 100644 --- a/server/providers/managed-auth.ts +++ b/server/providers/managed-auth.ts @@ -20,6 +20,7 @@ */ import { createHash, randomBytes, randomUUID } from 'node:crypto' import { SignJWT, jwtVerify } from 'jose' +import { IdentityConflictError } from './auth' import type { AuthProvider, AuthSession, AuthTokens, AuthUser, ProviderTokens } from './auth' import { decryptApiKey, encryptApiKey } from '../utils/encryption' import { getDb, getPostgresConfig } from './postgres-db/client' @@ -597,6 +598,47 @@ export function createManagedAuthProvider(): AuthProvider { return row ? toAuthUser(row) : null }, + async ensureUserForProviderAccount(input) { + const known = await this.getUserByProviderAccount(input.provider, input.accountId) + if (known) return known + + const db = getDb() + const now = new Date().toISOString() + const byEmail = await db + .selectFrom('auth.users') + .select('id') + .where(({ eb, fn }) => eb(fn('lower', ['email']), '=', input.email.toLowerCase())) + .executeTakeFirst() + + if (byEmail) { + // The email is provider-verified: the account joins this user. A user who already has a + // different account of this provider is not silently given a second one. + const other = await db + .selectFrom('auth.identities') + .select('provider_id') + .where('user_id', '=', byEmail.id) + .where('provider', '=', input.provider) + .executeTakeFirst() + if (other && other.provider_id !== input.accountId) throw new IdentityConflictError() + await recordIdentity(byEmail.id, input.provider, input.accountId, now) + return toAuthUser((await loadUserById(byEmail.id))!) + } + + const inserted = await db + .insertInto('auth.users') + .values({ + email: input.email, + provider: input.provider, + provider_account_id: input.accountId, + raw_user_meta_data: JSON.stringify({}), + email_verified_at: now, + }) + .returning('id') + .executeTakeFirst() + await recordIdentity(inserted!.id, input.provider, input.accountId, now) + return toAuthUser((await loadUserById(inserted!.id))!) + }, + async deleteUser(userId) { // Cascades: profiles → workspaces → … plus refresh/one-time tokens. await getDb().deleteFrom('auth.users').where('id', '=', userId).execute() diff --git a/server/providers/payment/plugins/polar.ts b/server/providers/payment/plugins/polar.ts index c1cc3094..e2c7da7c 100644 --- a/server/providers/payment/plugins/polar.ts +++ b/server/providers/payment/plugins/polar.ts @@ -20,6 +20,8 @@ import { Polar } from '@polar-sh/sdk' import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks' import { billingReasonOf } from '../billing-reason' import type { + BundleCheckoutInput, + BundleCheckoutResult, CanonicalWebhookEvent, CheckoutInput, CheckoutResult, @@ -37,6 +39,16 @@ interface PolarConfig { webhookSecret?: string starterProductId?: string proProductId?: string + /** + * "Migrate with Studio" bundle: per plan, the product the first (ad-hoc + * priced) invoice is sold on, and the yearly list product the subscription + * moves to for renewal. The bundle product carries the same benefits as its + * list product; only its price is replaced per checkout. + */ + starterBundleProductId?: string + proBundleProductId?: string + starterYearlyProductId?: string + proYearlyProductId?: string /** 'sandbox' | 'production' — Polar SDK server mode. Defaults to 'production'. */ server?: string } @@ -55,11 +67,23 @@ function buildProductMap(cfg: PolarConfig): Record { function planFromProductId(productId: string | undefined, productMap: Record): string | undefined { if (!productId) return undefined for (const [plan, id] of Object.entries(productMap)) { - if (id === productId) return plan + if (id === productId) return plan.split('#')[0] } return undefined } +/** Bundle and yearly products map to their plan too, so a subscription on either reads as Starter / Pro. */ +function extendWithBundleProducts(cfg: PolarConfig, productMap: Record): Record { + // `planFromProductId` walks plan → id; the bundle ids live under suffixed keys that resolve back to the plan. + return { + ...productMap, + ...(cfg.starterBundleProductId ? { 'starter#bundle': cfg.starterBundleProductId } : {}), + ...(cfg.proBundleProductId ? { 'pro#bundle': cfg.proBundleProductId } : {}), + ...(cfg.starterYearlyProductId ? { 'starter#yearly': cfg.starterYearlyProductId } : {}), + ...(cfg.proYearlyProductId ? { 'pro#yearly': cfg.proYearlyProductId } : {}), + } +} + function isoOrUndefined(value: Date | string | null | undefined): string | undefined { if (!value) return undefined if (value instanceof Date) return value.toISOString() @@ -128,6 +152,7 @@ function subscriptionToResult( event: canonicalEvent, workspaceId, plan: planFromProductId(sub.productId, productMap) ?? planFromMeta, + productId: sub.productId, subscriptionId: sub.id, customerId: sub.customerId, subscriptionStatus: sub.status, @@ -152,6 +177,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { const polar = new Polar({ accessToken, server }) const webhookSecret = cfg.webhookSecret ?? '' const productMap = buildProductMap(cfg) + const planMap = extendWithBundleProducts(cfg, productMap) return { async createCheckoutSession(input: CheckoutInput): Promise { @@ -221,7 +247,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { switch (event.type) { case 'subscription.created': - return subscriptionToResult('subscription.created', event.data as unknown as PolarSubscriptionLike, productMap) + return subscriptionToResult('subscription.created', event.data as unknown as PolarSubscriptionLike, planMap) // Every subscription lifecycle event is mapped by the state it // carries (`hasEnded`): a cancellation scheduled for the period end @@ -243,7 +269,7 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { subscriptionStatus: 'canceled', } } - const result = subscriptionToResult('subscription.updated', sub, productMap) + const result = subscriptionToResult('subscription.updated', sub, planMap) return event.type === 'subscription.past_due' ? { ...result, subscriptionStatus: 'past_due' } : result } @@ -283,6 +309,55 @@ function createPolarProvider(config: PaymentPluginConfig): PaymentProvider { } }, + async createBundleCheckout(input: BundleCheckoutInput): Promise { + const bundleProductId = input.plan === 'pro' ? cfg.proBundleProductId : cfg.starterBundleProductId + const targetProductId = input.plan === 'pro' ? cfg.proYearlyProductId : cfg.starterYearlyProductId + if (!bundleProductId || !targetProductId) { + throw new Error(`No Polar bundle/yearly product configured for plan: ${input.plan}`) + } + + const checkout = await polar.checkouts.create({ + products: [bundleProductId], + // The first invoice is the quoted total, not the product's catalogue price. + prices: { [bundleProductId]: [{ amountType: 'fixed', priceCurrency: 'usd', priceAmount: input.amountCents }] }, + customerEmail: input.customerEmail, + externalCustomerId: input.workspaceId, + successUrl: input.successUrl, + // The bundle is a paid first year: no trial, and no discount code can lower the quoted total. + allowTrial: false, + allowDiscountCodes: false, + metadata: { + ...input.metadata, + workspace_id: input.workspaceId, + plan: input.plan, + }, + customerMetadata: { workspace_id: input.workspaceId }, + }) + + return { + url: checkout.url, + sessionId: checkout.id, + expiresAt: isoOrUndefined(checkout.expiresAt) ?? new Date(Date.now() + 60 * 60 * 1000).toISOString(), + targetProductId, + } + }, + + async moveBundleSubscriptionToList(subscriptionId, plan) { + const targetProductId = plan === 'pro' ? cfg.proYearlyProductId : cfg.starterYearlyProductId + if (!targetProductId) throw new Error(`No Polar yearly product configured for plan: ${plan}`) + + const current = await polar.subscriptions.get({ id: subscriptionId }) + // Already there, or the move is scheduled: sending it again would only reset the pending update. + if (current.productId === targetProductId || current.pendingUpdate?.productId === targetProductId) { + return { productId: targetProductId, alreadyOnList: true } + } + await polar.subscriptions.update({ + id: subscriptionId, + subscriptionUpdate: { productId: targetProductId, prorationBehavior: 'next_period' }, + }) + return { productId: targetProductId, alreadyOnList: false } + }, + async cancelSubscription(subscriptionId: string): Promise { await polar.subscriptions.revoke({ id: subscriptionId }) }, diff --git a/server/providers/payment/plugins/stripe.ts b/server/providers/payment/plugins/stripe.ts index b14cf07c..4b293105 100644 --- a/server/providers/payment/plugins/stripe.ts +++ b/server/providers/payment/plugins/stripe.ts @@ -225,6 +225,14 @@ function createStripeProvider(config: PaymentPluginConfig): PaymentProvider { } }, + async createBundleCheckout(): Promise { + throw new Error('The Migrate bundle needs ad-hoc recurring prices, which only the Polar plugin supports') + }, + + async moveBundleSubscriptionToList(): Promise { + throw new Error('The Migrate bundle is Polar-only') + }, + async cancelSubscription(subscriptionId: string): Promise { await stripe.subscriptions.cancel(subscriptionId) }, diff --git a/server/providers/payment/types.ts b/server/providers/payment/types.ts index d906533e..ea0d7773 100644 --- a/server/providers/payment/types.ts +++ b/server/providers/payment/types.ts @@ -39,6 +39,29 @@ export interface CheckoutResult { sessionId: string } +/** + * A "Migrate with Studio" bundle checkout: one first invoice at a price Studio + * computed (Migrate fee + Studio year 1), on a yearly subscription that moves + * to the list product at the next period (`changeSubscriptionProduct`). + */ +export interface BundleCheckoutInput { + workspaceId: string + plan: 'starter' | 'pro' + customerEmail: string + /** The first invoice, in cents (USD). */ + amountCents: number + successUrl: string + /** Copied onto the checkout and the subscription (order, tenant, grant ids). */ + metadata: Record +} + +export interface BundleCheckoutResult extends CheckoutResult { + /** When the checkout stops being payable (ISO). */ + expiresAt: string + /** The list product the subscription must move to after it is created. */ + targetProductId: string +} + export interface PortalInput { workspaceId: string /** Provider-specific customer identifier (e.g. Stripe `cus_…`, Polar UUID). */ @@ -87,6 +110,8 @@ export interface WebhookResult { * webhook mark the grant used. */ migrateGrantId?: string + /** The product the subscription is on right now (subscription events). */ + productId?: string /** Provider invoice/order ID (for payment events). */ invoiceId?: string /** @@ -145,6 +170,20 @@ export interface PaymentProvider { /** Cancel a subscription (immediate). */ cancelSubscription: (subscriptionId: string) => Promise + /** + * Open a Migrate bundle checkout (see `BundleCheckoutInput`). Providers + * without ad-hoc recurring prices throw: the bundle is Polar-only. + */ + createBundleCheckout: (input: BundleCheckoutInput) => Promise + + /** + * Move a bundle subscription to the plan's yearly list product, effective at + * the next period (no charge now). The first invoice stays what it was; the + * renewal is the list price. Idempotent: a subscription already on the + * product is left alone. Returns the product the subscription ends on. + */ + moveBundleSubscriptionToList: (subscriptionId: string, plan: 'starter' | 'pro') => Promise<{ productId: string, alreadyOnList: boolean }> + /** * Record a usage event for metered/overage billing. * diff --git a/server/providers/postgres-db/migrate-grants.ts b/server/providers/postgres-db/migrate-grants.ts index 0edbbb13..707a5cd5 100644 --- a/server/providers/postgres-db/migrate-grants.ts +++ b/server/providers/postgres-db/migrate-grants.ts @@ -10,6 +10,10 @@ import { getAdmin, throwDbError } from './helpers' type MigrateGrantMethods = Pick< DatabaseProvider, | 'claimMigrateGrant' + | 'getMigrateGrantById' + | 'saveMigrateGrantCheckout' + | 'markMigrateBundleApplied' + | 'listPendingMigrateBundles' | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' @@ -86,11 +90,12 @@ export function migrateGrantMethods(): MigrateGrantMethods { claim_jti: input.claimJti, user_id: input.userId, plan: input.plan, - trial_days: input.trialDays, - repo_owner: input.repoOwner, - repo_name: input.repoName, + trial_days: input.trialDays ?? null, + repo_owner: input.repoOwner ?? null, + repo_name: input.repoName ?? null, email: input.email, origin: input.origin ?? null, + kind: input.kind ?? 'trial', }) .onConflict(oc => oc.column('order_id').doNothing()) .returningAll() @@ -118,6 +123,71 @@ export function migrateGrantMethods(): MigrateGrantMethods { } }, + async getMigrateGrantById(grantId) { + try { + const row = await getAdmin() + .selectFrom('migrate_grants') + .selectAll() + .where('id', '=', grantId) + .executeTakeFirst() + return (row as DatabaseRow | undefined) ?? null + } + catch (error) { + throwDbError(error) + } + }, + + async saveMigrateGrantCheckout(grantId, input) { + try { + await getAdmin() + .updateTable('migrate_grants') + .set({ + checkout_id: input.checkoutId, + checkout_url: input.checkoutUrl, + checkout_expires_at: input.checkoutExpiresAt, + amount_cents: input.amountCents, + bundle_target_product_id: input.targetProductId, + }) + .where('id', '=', grantId) + .execute() + } + catch (error) { + throwDbError(error) + } + }, + + async markMigrateBundleApplied(grantId) { + try { + await getAdmin() + .updateTable('migrate_grants') + .set(eb => ({ bundle_applied_at: eb.fn.coalesce('bundle_applied_at', eb.fn('now', [])) })) + .where('id', '=', grantId) + .execute() + } + catch (error) { + throwDbError(error) + } + }, + + async listPendingMigrateBundles(limit) { + try { + const rows = await getAdmin() + .selectFrom('migrate_grants') + .selectAll() + .where('kind', '=', 'bundle') + .where('redeemed_at', 'is not', null) + .where('bundle_target_product_id', 'is not', null) + .where('bundle_applied_at', 'is', null) + .orderBy('redeemed_at', 'asc') + .limit(limit) + .execute() + return rows as DatabaseRow[] + } + catch (error) { + throwDbError(error) + } + }, + async getMigrateGrantForUser(grantId, userId) { try { const row = await getAdmin() diff --git a/server/providers/postgres-db/types.ts b/server/providers/postgres-db/types.ts index 5797e754..e13f7b55 100644 --- a/server/providers/postgres-db/types.ts +++ b/server/providers/postgres-db/types.ts @@ -115,15 +115,24 @@ export interface MigrateGrantsTable { claim_jti: string user_id: string plan: string - trial_days: number - repo_owner: string - repo_name: string + /** NULL for a bundle grant (044). */ + trial_days: number | null + /** NULL for a bundle grant until the delivery repository reaches Studio (044). */ + repo_owner: string | null + repo_name: string | null email: string workspace_id: string | null bound_at: string | null redeemed_at: string | null redeemed_subscription_id: string | null origin: string | null + kind: Generated + checkout_id: string | null + checkout_url: string | null + checkout_expires_at: string | null + amount_cents: number | null + bundle_target_product_id: string | null + bundle_applied_at: string | null created_at: Generated } diff --git a/server/providers/supabase-auth.ts b/server/providers/supabase-auth.ts index 4562f72d..60ab2749 100644 --- a/server/providers/supabase-auth.ts +++ b/server/providers/supabase-auth.ts @@ -266,6 +266,25 @@ export function createSupabaseAuthProvider(): AuthProvider { return data?.user ? mapSupabaseUser(data.user) : null }, + async ensureUserForProviderAccount(input): Promise { + const known = await this.getUserByProviderAccount(input.provider, input.accountId) + if (known) return known + // The admin API cannot write `auth.identities`: an existing user with this email is + // returned as is, and GoTrue links the GitHub identity at their first GitHub sign-in + // (same verified email). A new user is created confirmed; the bootstrap trigger fires. + const byEmail = await this.getUserByEmail(input.email) + if (byEmail) return byEmail + const admin = createSupabaseAdminClient() + const { data, error } = await admin.auth.admin.createUser({ + email: input.email, + email_confirm: true, + app_metadata: { provider: input.provider }, + user_metadata: { provider_id: input.accountId }, + }) + if (error || !data.user) throw error ?? new Error('createUser returned no user') + return mapSupabaseUser(data.user) + }, + async deleteUser(userId: string): Promise { const admin = createSupabaseAdminClient() const { error } = await admin.auth.admin.deleteUser(userId) diff --git a/server/providers/supabase-db/migrate-grants.ts b/server/providers/supabase-db/migrate-grants.ts index cf4378f0..78192c41 100644 --- a/server/providers/supabase-db/migrate-grants.ts +++ b/server/providers/supabase-db/migrate-grants.ts @@ -10,6 +10,10 @@ import { getAdmin } from './helpers' type MigrateGrantMethods = Pick< DatabaseProvider, | 'claimMigrateGrant' + | 'getMigrateGrantById' + | 'saveMigrateGrantCheckout' + | 'markMigrateBundleApplied' + | 'listPendingMigrateBundles' | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' @@ -102,11 +106,12 @@ export function migrateGrantMethods(): MigrateGrantMethods { claim_jti: input.claimJti, user_id: input.userId, plan: input.plan, - trial_days: input.trialDays, - repo_owner: input.repoOwner, - repo_name: input.repoName, + trial_days: input.trialDays ?? null, + repo_owner: input.repoOwner ?? null, + repo_name: input.repoName ?? null, email: input.email, origin: input.origin ?? null, + kind: input.kind ?? 'trial', }, { onConflict: 'order_id', ignoreDuplicates: true }) .select() if (error) fail(error.message) @@ -131,6 +136,49 @@ export function migrateGrantMethods(): MigrateGrantMethods { return { grant: existing as DatabaseRow, created: false } }, + async getMigrateGrantById(grantId) { + const { data, error } = await getAdmin().from('migrate_grants').select('*').eq('id', grantId).maybeSingle() + if (error) fail(error.message) + return (data as DatabaseRow | null) ?? null + }, + + async saveMigrateGrantCheckout(grantId, input) { + const { error } = await getAdmin() + .from('migrate_grants') + .update({ + checkout_id: input.checkoutId, + checkout_url: input.checkoutUrl, + checkout_expires_at: input.checkoutExpiresAt, + amount_cents: input.amountCents, + bundle_target_product_id: input.targetProductId, + }) + .eq('id', grantId) + if (error) fail(error.message) + }, + + async markMigrateBundleApplied(grantId) { + const { error } = await getAdmin() + .from('migrate_grants') + .update({ bundle_applied_at: new Date().toISOString() }) + .eq('id', grantId) + .is('bundle_applied_at', null) + if (error) fail(error.message) + }, + + async listPendingMigrateBundles(limit) { + const { data, error } = await getAdmin() + .from('migrate_grants') + .select('*') + .eq('kind', 'bundle') + .not('redeemed_at', 'is', null) + .not('bundle_target_product_id', 'is', null) + .is('bundle_applied_at', null) + .order('redeemed_at', { ascending: true }) + .limit(limit) + if (error) fail(error.message) + return (data ?? []) as DatabaseRow[] + }, + async getMigrateGrantForUser(grantId, userId) { const { data, error } = await getAdmin() .from('migrate_grants') diff --git a/server/utils/migrate-bundle-subscription.ts b/server/utils/migrate-bundle-subscription.ts new file mode 100644 index 00000000..6fd16974 --- /dev/null +++ b/server/utils/migrate-bundle-subscription.ts @@ -0,0 +1,91 @@ +/** + * The second half of a "Migrate with Studio" bundle: moving its subscription + * from the ad-hoc priced bundle product to the plan's yearly list product. + * + * The first invoice rides on a price Studio set for one checkout. Polar keeps + * that price on the subscription, so if the move never happens the renewal + * charges it again (verified in sandbox 2026-10-03: ad-hoc $5 carried forever, + * a `next_period` product change gave the list price at renewal). So: + * + * - the billing webhook moves it as soon as the subscription exists; + * - a failed move is never silent: the grant keeps `bundle_applied_at` NULL and + * a scheduled job retries it; + * - a subscription still not moved 30 days before its renewal raises an alarm + * (an error-level log line `[migrate-bundle] ALARM`, which the platform's log + * alert watches), because from then on the customer is about to be charged the + * wrong amount. + */ +import type { DatabaseRow } from '../providers/database' +import type { PaymentProvider } from '../providers/payment/types' + +export const BUNDLE_ALARM_DAYS = 30 +const DAY_MS = 24 * 60 * 60 * 1000 + +export type BundleMoveResult = 'applied' | 'pending' | 'skipped' + +/** Move one grant's subscription to the list product; never throws (a failure leaves the grant pending). */ +export async function applyBundleListProduct( + payment: PaymentProvider, + grant: DatabaseRow, + subscriptionId: string, +): Promise { + if (grant.kind !== 'bundle' || !grant.bundle_target_product_id || grant.bundle_applied_at) return 'skipped' + const db = useDatabaseProvider() + try { + await payment.moveBundleSubscriptionToList(subscriptionId, grant.plan as 'starter' | 'pro') + await db.markMigrateBundleApplied(String(grant.id)) + return 'applied' + } + catch (err) { + // eslint-disable-next-line no-console -- ops visibility: the reconciler retries + console.error(`[migrate-bundle] move to list product failed for grant ${String(grant.id)}, subscription ${subscriptionId}:`, err) + return 'pending' + } +} + +export interface BundleReconcileSummary { + checked: number + applied: number + stillPending: number + alarms: number +} + +/** Retry every pending move and raise the alarm for those too close to renewal. */ +export async function reconcileMigrateBundles(payment: PaymentProvider, now: Date = new Date()): Promise { + const db = useDatabaseProvider() + const pending = await db.listPendingMigrateBundles(100) + const summary: BundleReconcileSummary = { checked: pending.length, applied: 0, stillPending: 0, alarms: 0 } + for (const grant of pending) { + const subscriptionId = grant.redeemed_subscription_id as string | null + if (!subscriptionId) continue + const result = await applyBundleListProduct(payment, grant, subscriptionId) + if (result === 'applied') { + summary.applied++ + continue + } + summary.stillPending++ + const account = grant.workspace_id ? await db.getActivePaymentAccount(String(grant.workspace_id)) : null + const renewsAt = account?.current_period_end ? new Date(String(account.current_period_end)) : null + // Unknown renewal date: alarm anyway, an unmoved subscription with no date is not safe to leave. + if (!renewsAt || renewsAt.getTime() - now.getTime() <= BUNDLE_ALARM_DAYS * DAY_MS) { + summary.alarms++ + // eslint-disable-next-line no-console -- the alarm: watched by the platform's log alert + console.error(`[migrate-bundle] ALARM grant ${String(grant.id)} (subscription ${subscriptionId}) is still on the ad-hoc price; renews ${renewsAt?.toISOString() ?? 'at an unknown date'}`) + } + } + return summary +} + +/** + * A subscription started from a Migrate grant's checkout uses the grant up + * (no second included trial after cancel-and-resubscribe), and a bundle + * grant's subscription moves to its list product. Idempotent: whichever of + * `subscription.created` / `.updated` arrives first does the work. + */ +export async function redeemMigrateGrant(payment: PaymentProvider, grantId: string, subscriptionId: string | null): Promise { + const db = useDatabaseProvider() + await db.markMigrateGrantRedeemed(grantId, subscriptionId) + if (!subscriptionId) return + const grant = await db.getMigrateGrantById(grantId) + if (grant) await applyBundleListProduct(payment, grant, subscriptionId) +} diff --git a/server/utils/migrate-grant.ts b/server/utils/migrate-grant.ts index aa886e4f..4121a637 100644 --- a/server/utils/migrate-grant.ts +++ b/server/utils/migrate-grant.ts @@ -25,8 +25,10 @@ export type MigrateGrantState = 'claimed' | 'bound' | 'redeemed' export interface MigrateGrantView { id: string plan: 'starter' | 'pro' - trialDays: number - repo: { owner: string, name: string } + /** Null for a bundle grant (it opens no included trial). */ + trialDays: number | null + /** Null for a bundle grant until the delivery repository reaches Studio. */ + repo: { owner: string, name: string } | null email: string workspaceId: string | null state: MigrateGrantState @@ -38,8 +40,8 @@ export function migrateGrantView(row: DatabaseRow): MigrateGrantView { return { id: row.id as string, plan: row.plan as 'starter' | 'pro', - trialDays: row.trial_days as number, - repo: { owner: row.repo_owner as string, name: row.repo_name as string }, + trialDays: (row.trial_days as number | null) ?? null, + repo: row.repo_owner && row.repo_name ? { owner: row.repo_owner as string, name: row.repo_name as string } : null, email: row.email as string, workspaceId: (row.workspace_id as string | null) ?? null, state, @@ -64,6 +66,8 @@ export async function migrateGrantDestination(session: { accessToken: string, us const db = useDatabaseProvider() const workspace = await db.getWorkspaceForUser(session.accessToken, session.user.id, workspaceId, ['owner', 'admin'], 'id, slug') if (!workspace) return null + // A bundle grant has no repository until delivery: no project to point at yet. + if (!row.repo_owner || !row.repo_name) return { workspaceSlug: workspace.slug as string, projectId: null } const repo = `${row.repo_owner as string}/${row.repo_name as string}`.toLowerCase() const projects = await db.listWorkspaceProjects(session.accessToken, workspaceId) const project = projects.find(p => typeof p.repo_full_name === 'string' && p.repo_full_name.toLowerCase() === repo) diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts new file mode 100644 index 00000000..47371876 --- /dev/null +++ b/server/utils/migrate-provision.ts @@ -0,0 +1,153 @@ +/** + * Provision a "Migrate with Studio" bundle (S2). + * + * Migrate has sold the customer one order: its own fee plus Studio year 1. + * Before they pay, Migrate asks Studio — server to server, signed — to open + * the Studio side: find or create the account behind the GitHub user, give it + * a grant for the order, and open ONE Polar checkout whose first invoice is the + * total Migrate quoted. Studio sets no price of its own on this path; it only + * agrees or refuses: + * + * - the quote must be what Studio computes now (Migrate fee + the Studio line + * from the account's state), else `quote_changed` and Migrate re-quotes; + * - only a `none` account is provisioned here: a customer who already has a plan + * (`covers`, `too_small`) or a workspace with a live subscription needs a + * different flow (S3) and is refused with a clear code instead of a checkout + * at the wrong amount; + * - the return address must be on this Studio's Migrate allowlist. + * + * One grant per order. A repeated provision returns the checkout the grant + * already opened while it is still payable and the amount is unchanged; it + * never opens a second one that could be paid twice. + */ +import type { MigrateProvisionResponse, MigrateStudioClaimV2 } from '@contentrain/types' +import { validateMigrateProvisionResponse } from '@contentrain/types' +import { IdentityConflictError } from '../providers/auth' +import { resolveMigrateAccountState } from './migrate-account-state' +import { migrateExportOrigins } from './migrate-comments-export' + +/** A checkout is reused only while it has at least this long left to be paid. */ +const MIN_REMAINING_MS = 5 * 60 * 1000 + +function fail(statusCode: number, key: string): never { + throw createError({ statusCode, message: errorMessage(key) }) +} + +export function isAllowedReturnUrl(returnUrl: string, origins: string[]): boolean { + try { + return origins.includes(new URL(returnUrl).origin) + } + catch { + return false + } +} + +/** The workspace the bundle's subscription belongs on: the account's personal one, else its first. */ +async function bundleWorkspace(userId: string): Promise<{ id: string, slug: string, name: string }> { + const db = useDatabaseProvider() + const owned = await db.listOwnedWorkspacesAdmin(userId) + const chosen = owned.find(w => w.type === 'primary') ?? owned[0] + if (!chosen) throw createError({ statusCode: 500, message: errorMessage('generic.server_error') }) + const row = await db.getWorkspaceById(String(chosen.id), 'id, slug, name') + if (!row) throw createError({ statusCode: 500, message: errorMessage('generic.server_error') }) + return { id: String(row.id), slug: String(row.slug), name: String(row.name) } +} + +export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: Date = new Date()): Promise { + if (!isAllowedReturnUrl(claim.return_url, migrateExportOrigins())) fail(400, 'migrate.return_url_not_allowed') + // An unverified email never creates or links an account. + if (!claim.email_verified) fail(400, 'migrate.email_unverified') + + // Studio agrees the quote or refuses it; it never prices on this path. + const account = await resolveMigrateAccountState(claim.github_user_id, claim.plan) + if (account.state !== 'none') fail(409, 'migrate.bundle_state_unsupported') + if (claim.billing.migrate_fee_cents + account.year1_cents !== claim.billing.quoted_total_cents) fail(409, 'migrate.quote_changed') + + let user + try { + user = await useAuthProvider().ensureUserForProviderAccount({ provider: 'github', accountId: claim.github_user_id, email: claim.email }) + } + catch (err) { + if (err instanceof IdentityConflictError) fail(409, 'migrate.identity_conflict') + throw err + } + + const db = useDatabaseProvider() + const workspace = await bundleWorkspace(user.id) + const existingAccount = await db.getActivePaymentAccount(workspace.id) + const status = existingAccount?.subscription_status as string | null | undefined + if (existingAccount?.subscription_id && status && !['canceled', 'incomplete_expired'].includes(status)) fail(409, 'billing.subscription_exists') + + const { grant } = await db.claimMigrateGrant({ + orderId: claim.order_id, + claimJti: claim.jti, + userId: user.id, + plan: claim.plan, + email: claim.email, + origin: claim.origin ?? null, + kind: 'bundle', + }) + // The order belongs to another account, or was opened as something else: never reuse it. + if (grant.user_id !== user.id || grant.kind !== 'bundle') fail(409, 'migrate.claim_taken') + if (grant.redeemed_at) fail(409, 'migrate.grant_used') + const bound = await db.bindMigrateGrantWorkspace(String(grant.id), workspace.id) + if (!bound) fail(409, 'migrate.grant_bound_elsewhere') + + const quoted = claim.billing.quoted_total_cents + const storedExpires = grant.checkout_expires_at ? new Date(String(grant.checkout_expires_at)) : null + let checkoutUrl = grant.checkout_url as string | null + let expiresAt = storedExpires + if (!checkoutUrl || !storedExpires || grant.amount_cents !== quoted || storedExpires.getTime() - now.getTime() < MIN_REMAINING_MS) { + const payment = usePaymentProvider() + if (!payment) fail(503, 'generic.server_error') + // Two provisions of one order in the same moment would open two checkouts: the second waits. + const rate = await checkRateLimit(`migrate-provision:${claim.order_id}`, 1, 10_000) + if (!rate.allowed) fail(429, 'auth.rate_limited') + + const siteUrl = useRuntimeConfig().public.siteUrl as string + try { + const checkout = await payment.createBundleCheckout({ + workspaceId: workspace.id, + plan: claim.plan, + customerEmail: claim.email, + amountCents: quoted, + successUrl: claim.return_url, + metadata: { + order_id: claim.order_id, + tenant_id: claim.sub, + migrate_grant_id: String(grant.id), + migrate_bundle: 'true', + studio_url: siteUrl, + }, + }) + await db.saveMigrateGrantCheckout(String(grant.id), { + checkoutId: checkout.sessionId, + checkoutUrl: checkout.url, + checkoutExpiresAt: checkout.expiresAt, + amountCents: quoted, + targetProductId: checkout.targetProductId, + }) + checkoutUrl = checkout.url + expiresAt = new Date(checkout.expiresAt) + } + catch (err) { + // eslint-disable-next-line no-console -- ops visibility for provider failures + console.error('[migrate-provision] createBundleCheckout failed:', err) + throw createError({ statusCode: 502, message: errorMessage('billing.provider_unavailable') }) + } + } + + const response: MigrateProvisionResponse = { + grant_id: String(grant.id), + state: bound.redeemed_at ? 'redeemed' : 'bound', + plan: claim.plan, + workspace_slug: workspace.slug, + checkout_url: checkoutUrl as string, + amount_cents: quoted, + checkout_expires_at: Math.floor((expiresAt as Date).getTime() / 1000), + } + // Fail closed on our own answer: Migrate redirects a browser to it. + if (!validateMigrateProvisionResponse(response, { quoted_total_cents: quoted, now: Math.floor(now.getTime() / 1000) }).ok) + fail(502, 'billing.provider_unavailable') + return response +} diff --git a/supabase/migrations/044_migrate_bundle_grants.sql b/supabase/migrations/044_migrate_bundle_grants.sql new file mode 100644 index 00000000..e6f3bc1c --- /dev/null +++ b/supabase/migrations/044_migrate_bundle_grants.sql @@ -0,0 +1,41 @@ +-- 044: a grant for a "Migrate with Studio" bundle (provision, S2). +-- +-- The bundle is sold by Migrate before anything is delivered, so its grant has +-- neither an included trial nor a repository yet: `kind = 'bundle'` rows carry +-- `trial_days` NULL and `repo_owner` / `repo_name` NULL until the delivery +-- repository reaches Studio (install-url / claim step). A trial grant (031) +-- keeps all three, and says so with the CHECKs below. +-- +-- The grant also remembers the Polar checkout it opened, so a repeated +-- provision for the order returns the same checkout while it is payable +-- instead of opening a second one that could be paid twice. +-- +-- The first invoice rides on an ad-hoc price; the webhook moves the new +-- subscription to the list product (effective at the next period). Until it +-- does, renewal would charge the ad-hoc price again, so the target product and +-- the moment the move was confirmed live here for the reconciler: +-- `bundle_target_product_id` set + `bundle_applied_at` NULL = still to move. +-- +-- Service-role only like the rest of the table. + +ALTER TABLE public.migrate_grants + ADD COLUMN kind text NOT NULL DEFAULT 'trial' CHECK (kind IN ('trial', 'bundle')), + ADD COLUMN checkout_id text, + ADD COLUMN checkout_url text, + ADD COLUMN checkout_expires_at timestamp with time zone, + ADD COLUMN amount_cents integer CHECK (amount_cents IS NULL OR amount_cents > 0), + ADD COLUMN bundle_target_product_id text, + ADD COLUMN bundle_applied_at timestamp with time zone; + +ALTER TABLE public.migrate_grants + ALTER COLUMN trial_days DROP NOT NULL, + ALTER COLUMN repo_owner DROP NOT NULL, + ALTER COLUMN repo_name DROP NOT NULL; + +ALTER TABLE public.migrate_grants + ADD CONSTRAINT migrate_grants_trial_shape CHECK (kind <> 'trial' OR (trial_days IS NOT NULL AND repo_owner IS NOT NULL AND repo_name IS NOT NULL)), + ADD CONSTRAINT migrate_grants_repo_pair CHECK ((repo_owner IS NULL) = (repo_name IS NULL)); + +-- The reconciler's work list: bundle subscriptions not yet moved to the list product. +CREATE INDEX idx_migrate_grants_bundle_pending ON public.migrate_grants (redeemed_at) + WHERE kind = 'bundle' AND bundle_target_product_id IS NOT NULL AND bundle_applied_at IS NULL; diff --git a/tests/contract/managed-auth.contract.test.ts b/tests/contract/managed-auth.contract.test.ts index 69a8cead..e08c7f9b 100644 --- a/tests/contract/managed-auth.contract.test.ts +++ b/tests/contract/managed-auth.contract.test.ts @@ -30,6 +30,46 @@ describe('managed-auth provider (contract)', () => { await deleteSeededUser(id).catch(() => {}) }) + describe('ensureUserForProviderAccount (Migrate provision: no sign-in)', () => { + it('creates the user and its personal workspace, and finds it again by the GitHub id', async () => { + const accountId = `gh-${randomUUID()}` + const email = `ensure-${randomUUID()}@managed.test` + const user = await auth.ensureUserForProviderAccount({ provider: 'github', accountId, email }) + cleanupUserIds.push(user.id) + expect(user).toMatchObject({ email, provider: 'github', providerAccountId: accountId }) + + const workspace = await sql<{ count: number }>` + SELECT count(*)::int AS count FROM public.workspaces WHERE owner_id = ${user.id} AND type = 'primary' + `.execute(getDb()) + expect(workspace.rows[0]!.count).toBe(1) + + expect((await auth.ensureUserForProviderAccount({ provider: 'github', accountId, email }))?.id).toBe(user.id) + expect((await auth.getUserByProviderAccount('github', accountId))?.id).toBe(user.id) + }) + + it('links the GitHub account to the user who already has that email, leaving their profile as it is', async () => { + const email = `ensure-link-${randomUUID()}@managed.test` + const accountId = `gh-${randomUUID()}` + const session = await completeOAuthSignIn({ provider: 'google', providerAccountId: `g-${randomUUID()}`, email, name: 'Kept Name', userName: 'kept', avatarUrl: 'https://avatars.example/kept.png' }) + cleanupUserIds.push(session.user.id) + + const user = await auth.ensureUserForProviderAccount({ provider: 'github', accountId, email: email.toUpperCase() }) + expect(user.id).toBe(session.user.id) + expect((await auth.getUserByProviderAccount('github', accountId))?.id).toBe(session.user.id) + // The stored name and avatar are not overwritten by a call that knows neither. + expect(user.avatarUrl).toBe('https://avatars.example/kept.png') + const profile = await sql<{ display_name: string }>`SELECT display_name FROM public.profiles WHERE id = ${session.user.id}`.execute(getDb()) + expect(profile.rows[0]!.display_name).toBe('Kept Name') + }) + + it('refuses to give a user with one GitHub account a second', async () => { + const email = `ensure-conflict-${randomUUID()}@managed.test` + const session = await completeOAuthSignIn({ provider: 'github', providerAccountId: `gh-${randomUUID()}`, email, name: null, userName: null, avatarUrl: null }) + cleanupUserIds.push(session.user.id) + await expect(auth.ensureUserForProviderAccount({ provider: 'github', accountId: `gh-${randomUUID()}`, email })).rejects.toThrow(/different provider account/) + }) + }) + it('OAuth sign-in creates the user through the signup bootstrap chain', async () => { const email = `oauth-${randomUUID()}@managed.test` const session = await completeOAuthSignIn({ diff --git a/tests/contract/migrate-grants.contract.test.ts b/tests/contract/migrate-grants.contract.test.ts index 16ee4de8..3795cc29 100644 --- a/tests/contract/migrate-grants.contract.test.ts +++ b/tests/contract/migrate-grants.contract.test.ts @@ -100,6 +100,64 @@ describe('postgres-db migrate-grants (contract)', () => { expect(await methods.getMigrateGrantOrigin(owner.workspaceId, 'acme')).toBeNull() }) + describe('bundle grants (migration 044)', () => { + const bundle = (suffix: string) => methods.claimMigrateGrant({ + orderId: `${orderId}-b-${suffix}`, + claimJti: `jti-b-${suffix}`, + userId: owner.userId, + plan: 'pro', + email: 'owner@example.com', + kind: 'bundle', + }) + + it('has no trial and no repository until delivery, and is found by id', async () => { + const { grant, created } = await bundle('shape') + expect(created).toBe(true) + expect(grant).toMatchObject({ kind: 'bundle', trial_days: null, repo_owner: null, repo_name: null }) + expect(await methods.getMigrateGrantById(grant.id as string)).toMatchObject({ id: grant.id, kind: 'bundle' }) + expect(await methods.getMigrateGrantById('00000000-0000-0000-0000-000000000000')).toBeNull() + }) + + it('a trial grant still needs its trial days and repository', async () => { + await expect(methods.claimMigrateGrant({ + orderId: `${orderId}-b-trial`, claimJti: 'jti-b-trial', userId: owner.userId, plan: 'pro', email: 'owner@example.com', + })).rejects.toThrow() + }) + + it('remembers the checkout it opened and the product the subscription must move to', async () => { + const { grant } = await bundle('checkout') + await methods.saveMigrateGrantCheckout(grant.id as string, { + checkoutId: 'co_1', + checkoutUrl: 'https://sandbox.polar.sh/checkout/c_1', + checkoutExpiresAt: '2030-01-01T00:00:00.000Z', + amountCents: 64100, + targetProductId: 'prod_pro_y', + }) + expect(await methods.getMigrateGrantById(grant.id as string)).toMatchObject({ + checkout_id: 'co_1', checkout_url: 'https://sandbox.polar.sh/checkout/c_1', amount_cents: 64100, bundle_target_product_id: 'prod_pro_y', bundle_applied_at: null, + }) + }) + + it('lists a redeemed bundle until its move is recorded, oldest first, and only once recorded does it leave', async () => { + const mine = (await bundle('pending')).grant.id as string + const notRedeemed = (await bundle('unpaid')).grant.id as string + for (const id of [mine, notRedeemed]) { + await methods.saveMigrateGrantCheckout(id, { checkoutId: `co_${id}`, checkoutUrl: 'https://sandbox.polar.sh/checkout/c', checkoutExpiresAt: '2030-01-01T00:00:00.000Z', amountCents: 100, targetProductId: 'prod_y' }) + } + await methods.markMigrateGrantRedeemed(mine, 'sub_pending') + + const ids = async () => (await methods.listPendingMigrateBundles(500)).map(row => row.id) + expect(await ids()).toContain(mine) + expect(await ids()).not.toContain(notRedeemed) + + await methods.markMigrateBundleApplied(mine) + expect(await ids()).not.toContain(mine) + const applied = String((await methods.getMigrateGrantById(mine))!.bundle_applied_at) + await methods.markMigrateBundleApplied(mine) + expect(String((await methods.getMigrateGrantById(mine))!.bundle_applied_at)).toBe(applied) + }) + }) + describe('comments export held on the grant (migration 040)', () => { const payload = { format: 'contentrain-comments@1', comments: [{ id: 1 }] } const future = () => new Date(Date.now() + 3600_000).toISOString() diff --git a/tests/integration/billing-webhook.integration.test.ts b/tests/integration/billing-webhook.integration.test.ts index 937f8803..63391cd6 100644 --- a/tests/integration/billing-webhook.integration.test.ts +++ b/tests/integration/billing-webhook.integration.test.ts @@ -22,10 +22,17 @@ describe('billing webhook integration', () => { const setPaymentAccountCreditUnit = vi.fn().mockResolvedValue(false) let handleWebhookMock: ReturnType + // The real util (auto-imported in Nitro) marks the grant, then moves a bundle's subscription; the + // move itself is covered in migrate-bundle-subscription.test.ts, here only what the webhook hands it. + let redeemMigrateGrant: ReturnType beforeEach(() => { vi.resetModules() handleWebhookMock = vi.fn() + redeemMigrateGrant = vi.fn(async (_payment: unknown, grantId: string, subscriptionId: string | null) => { + await (globalThis as unknown as { useDatabaseProvider: () => { markMigrateGrantRedeemed: (g: string, s: string | null) => Promise } }).useDatabaseProvider().markMigrateGrantRedeemed(grantId, subscriptionId) + }) + vi.stubGlobal('redeemMigrateGrant', redeemMigrateGrant) vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) vi.stubGlobal('createError', createErrorLike) vi.stubGlobal('readRawBody', vi.fn().mockResolvedValue('{}')) @@ -195,6 +202,7 @@ describe('billing webhook integration', () => { const handler = await mockPluginAndLoadHandler() await handler({ context: {} } as never) expect(markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-1', 'sub_123') + expect(redeemMigrateGrant).toHaveBeenCalledWith(expect.anything(), 'grant-1', 'sub_123') // The trial cap tells a Migrate trial apart by this mark. expect(upsertPaymentAccount).toHaveBeenCalledWith(expect.objectContaining({ pluginMetadata: expect.objectContaining({ trial_origin: 'migrate' }), diff --git a/tests/unit/migrate-bundle-polar.test.ts b/tests/unit/migrate-bundle-polar.test.ts new file mode 100644 index 00000000..1184c970 --- /dev/null +++ b/tests/unit/migrate-bundle-polar.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const checkoutsCreate = vi.fn() +const subscriptionsGet = vi.fn() +const subscriptionsUpdate = vi.fn() +vi.mock('@polar-sh/sdk', () => ({ + Polar: class { + checkouts = { create: checkoutsCreate } + subscriptions = { get: subscriptionsGet, update: subscriptionsUpdate } + }, +})) +const validateEvent = vi.fn() +vi.mock('@polar-sh/sdk/webhooks', () => ({ + validateEvent: (...args: unknown[]) => validateEvent(...args), + WebhookVerificationError: class extends Error {}, +})) + +const polarConfig = { + accessToken: 'tok', + webhookSecret: 'sec', + starterProductId: 'prod_starter_m', + proProductId: 'prod_pro_m', + starterBundleProductId: 'prod_starter_bundle', + proBundleProductId: 'prod_pro_bundle', + starterYearlyProductId: 'prod_starter_y', + proYearlyProductId: 'prod_pro_y', +} + +async function polar(config: Record = polarConfig) { + const { polarPlugin } = await import('../../server/providers/payment/plugins/polar') + return polarPlugin.create({ polar: config } as never) +} + +const input = { + workspaceId: 'ws-1', + plan: 'pro' as const, + customerEmail: 'owner@example.com', + amountCents: 64100, + successUrl: 'https://migrate.contentrain.io/orders/ord_1?studio=done', + metadata: { order_id: 'ord_1', tenant_id: 'ten_1', migrate_grant_id: 'grant-1', workspace_id: 'ws-evil', plan: 'starter' }, +} + +describe('polar bundle checkout', () => { + beforeEach(() => { + checkoutsCreate.mockReset().mockResolvedValue({ url: 'https://sandbox.polar.sh/checkout/c_1', id: 'co_1', expiresAt: new Date('2026-10-04T10:00:00Z') }) + }) + + it('sells the bundle product at the quoted total, with no trial and no discount code', async () => { + const result = await (await polar()).createBundleCheckout(input) + + expect(checkoutsCreate).toHaveBeenCalledWith(expect.objectContaining({ + products: ['prod_pro_bundle'], + prices: { prod_pro_bundle: [{ amountType: 'fixed', priceCurrency: 'usd', priceAmount: 64100 }] }, + customerEmail: 'owner@example.com', + externalCustomerId: 'ws-1', + successUrl: input.successUrl, + allowTrial: false, + allowDiscountCodes: false, + })) + expect(result).toEqual({ + url: 'https://sandbox.polar.sh/checkout/c_1', + sessionId: 'co_1', + expiresAt: '2026-10-04T10:00:00.000Z', + targetProductId: 'prod_pro_y', + }) + }) + + it('carries order, tenant and grant to the subscription, and metadata cannot overwrite the workspace or plan', async () => { + await (await polar()).createBundleCheckout(input) + expect(checkoutsCreate.mock.calls[0]![0]).toMatchObject({ + metadata: { order_id: 'ord_1', tenant_id: 'ten_1', migrate_grant_id: 'grant-1', workspace_id: 'ws-1', plan: 'pro' }, + }) + }) + + it('uses the starter products for a starter bundle', async () => { + const result = await (await polar()).createBundleCheckout({ ...input, plan: 'starter', amountCents: 24900 }) + expect(checkoutsCreate.mock.calls[0]![0]).toMatchObject({ products: ['prod_starter_bundle'] }) + expect(result.targetProductId).toBe('prod_starter_y') + }) + + it('refuses when the bundle or yearly product is not configured (the bundle is off)', async () => { + const { starterBundleProductId: _s, proBundleProductId: _p, ...partial } = polarConfig + await expect((await polar(partial)).createBundleCheckout(input)).rejects.toThrow(/No Polar bundle\/yearly product/) + expect(checkoutsCreate).not.toHaveBeenCalled() + }) +}) + +describe('polar: moving a bundle subscription to its list product', () => { + beforeEach(() => { + subscriptionsGet.mockReset() + subscriptionsUpdate.mockReset().mockResolvedValue({}) + }) + + it('schedules the yearly product for the next period, charging nothing now', async () => { + subscriptionsGet.mockResolvedValue({ productId: 'prod_pro_bundle', pendingUpdate: null }) + const result = await (await polar()).moveBundleSubscriptionToList('sub_1', 'pro') + + expect(subscriptionsUpdate).toHaveBeenCalledWith({ + id: 'sub_1', + subscriptionUpdate: { productId: 'prod_pro_y', prorationBehavior: 'next_period' }, + }) + expect(result).toEqual({ productId: 'prod_pro_y', alreadyOnList: false }) + }) + + it('does nothing when the subscription is on the list product, or the move is already scheduled', async () => { + const provider = await polar() + subscriptionsGet.mockResolvedValueOnce({ productId: 'prod_pro_y', pendingUpdate: null }) + expect(await provider.moveBundleSubscriptionToList('sub_1', 'pro')).toEqual({ productId: 'prod_pro_y', alreadyOnList: true }) + subscriptionsGet.mockResolvedValueOnce({ productId: 'prod_pro_bundle', pendingUpdate: { productId: 'prod_pro_y' } }) + expect(await provider.moveBundleSubscriptionToList('sub_1', 'pro')).toEqual({ productId: 'prod_pro_y', alreadyOnList: true }) + expect(subscriptionsUpdate).not.toHaveBeenCalled() + }) + + it('lets the failure out, so the caller keeps the subscription pending', async () => { + subscriptionsGet.mockResolvedValue({ productId: 'prod_pro_bundle', pendingUpdate: null }) + subscriptionsUpdate.mockRejectedValue(new Error('polar down')) + await expect((await polar()).moveBundleSubscriptionToList('sub_1', 'pro')).rejects.toThrow('polar down') + }) +}) + +describe('polar webhook: bundle and yearly products read as their plan', () => { + const subscription = (productId: string, metadata: Record = {}) => ({ + type: 'subscription.created', + data: { id: 'sub_1', status: 'active', customerId: 'cus_1', productId, currentPeriodStart: null, currentPeriodEnd: null, trialEnd: null, cancelAtPeriodEnd: false, metadata }, + }) + + it.each([ + ['prod_pro_bundle', 'pro'], + ['prod_pro_y', 'pro'], + ['prod_starter_bundle', 'starter'], + ['prod_starter_y', 'starter'], + ['prod_pro_m', 'pro'], + ])('%s is %s, and the result names the product', async (productId, plan) => { + validateEvent.mockReturnValue(subscription(productId, { workspace_id: 'ws-1', migrate_grant_id: 'grant-1' })) + const result = await (await polar()).handleWebhook('{}', {}) + expect(result).toMatchObject({ event: 'subscription.created', plan, productId, migrateGrantId: 'grant-1', workspaceId: 'ws-1' }) + }) +}) diff --git a/tests/unit/migrate-bundle-subscription.test.ts b/tests/unit/migrate-bundle-subscription.test.ts new file mode 100644 index 00000000..4b8e5393 --- /dev/null +++ b/tests/unit/migrate-bundle-subscription.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const DAY = 24 * 60 * 60 * 1000 +const now = new Date('2026-10-10T00:00:00Z') + +const bundleGrant = (overrides: Record = {}) => ({ + id: 'grant-1', + kind: 'bundle', + plan: 'pro', + workspace_id: 'ws-1', + redeemed_subscription_id: 'sub_1', + bundle_target_product_id: 'prod_pro_y', + bundle_applied_at: null, + ...overrides, +}) + +describe('bundle subscription: move to the list product', () => { + let db: Record> + let payment: { moveBundleSubscriptionToList: ReturnType } + let errorLog: ReturnType + + beforeEach(() => { + vi.resetModules() + db = { + markMigrateGrantRedeemed: vi.fn().mockResolvedValue(undefined), + getMigrateGrantById: vi.fn().mockResolvedValue(bundleGrant()), + markMigrateBundleApplied: vi.fn().mockResolvedValue(undefined), + listPendingMigrateBundles: vi.fn().mockResolvedValue([]), + getActivePaymentAccount: vi.fn().mockResolvedValue(null), + } + payment = { moveBundleSubscriptionToList: vi.fn().mockResolvedValue({ productId: 'prod_pro_y', alreadyOnList: false }) } + vi.stubGlobal('useDatabaseProvider', () => db) + errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + errorLog.mockRestore() + vi.unstubAllGlobals() + }) + + const load = () => import('../../server/utils/migrate-bundle-subscription') + + it('moves the subscription and records it, once', async () => { + const { applyBundleListProduct } = await load() + expect(await applyBundleListProduct(payment as never, bundleGrant() as never, 'sub_1')).toBe('applied') + expect(payment.moveBundleSubscriptionToList).toHaveBeenCalledWith('sub_1', 'pro') + expect(db.markMigrateBundleApplied).toHaveBeenCalledWith('grant-1') + }) + + it.each([ + ['a trial grant', { kind: 'trial' }], + ['a bundle with no target product', { bundle_target_product_id: null }], + ['a bundle already moved', { bundle_applied_at: '2026-10-01T00:00:00Z' }], + ])('leaves %s alone', async (_label, overrides) => { + const { applyBundleListProduct } = await load() + expect(await applyBundleListProduct(payment as never, bundleGrant(overrides) as never, 'sub_1')).toBe('skipped') + expect(payment.moveBundleSubscriptionToList).not.toHaveBeenCalled() + }) + + it('a failed move is logged and left pending (never thrown, never marked applied)', async () => { + payment.moveBundleSubscriptionToList.mockRejectedValue(new Error('polar down')) + const { applyBundleListProduct } = await load() + expect(await applyBundleListProduct(payment as never, bundleGrant() as never, 'sub_1')).toBe('pending') + expect(db.markMigrateBundleApplied).not.toHaveBeenCalled() + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining('move to list product failed'), expect.any(Error)) + }) + + it('redeeming marks the grant used and then moves a bundle; a plain grant is only marked', async () => { + const { redeemMigrateGrant } = await load() + await redeemMigrateGrant(payment as never, 'grant-1', 'sub_1') + expect(db.markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-1', 'sub_1') + expect(payment.moveBundleSubscriptionToList).toHaveBeenCalledTimes(1) + + payment.moveBundleSubscriptionToList.mockClear() + db.getMigrateGrantById.mockResolvedValue(bundleGrant({ kind: 'trial' })) + await redeemMigrateGrant(payment as never, 'grant-2', 'sub_2') + expect(db.markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-2', 'sub_2') + expect(payment.moveBundleSubscriptionToList).not.toHaveBeenCalled() + }) + + describe('reconciler', () => { + it('retries pending moves and counts those it fixed', async () => { + db.listPendingMigrateBundles.mockResolvedValue([bundleGrant(), bundleGrant({ id: 'grant-2', redeemed_subscription_id: 'sub_2' })]) + const { reconcileMigrateBundles } = await load() + expect(await reconcileMigrateBundles(payment as never, now)).toEqual({ checked: 2, applied: 2, stillPending: 0, alarms: 0 }) + }) + + it('stays quiet about a failing move more than 30 days from renewal, and alarms within 30', async () => { + payment.moveBundleSubscriptionToList.mockRejectedValue(new Error('polar down')) + db.listPendingMigrateBundles.mockResolvedValue([bundleGrant()]) + const { reconcileMigrateBundles } = await load() + + db.getActivePaymentAccount.mockResolvedValue({ current_period_end: new Date(now.getTime() + 60 * DAY).toISOString() }) + expect(await reconcileMigrateBundles(payment as never, now)).toMatchObject({ stillPending: 1, alarms: 0 }) + expect(errorLog.mock.calls.some(call => String(call[0]).includes('ALARM'))).toBe(false) + + db.getActivePaymentAccount.mockResolvedValue({ current_period_end: new Date(now.getTime() + 29 * DAY).toISOString() }) + expect(await reconcileMigrateBundles(payment as never, now)).toMatchObject({ stillPending: 1, alarms: 1 }) + expect(errorLog.mock.calls.some(call => String(call[0]).includes('[migrate-bundle] ALARM grant grant-1'))).toBe(true) + }) + + it('alarms when the renewal date is unknown', async () => { + payment.moveBundleSubscriptionToList.mockRejectedValue(new Error('polar down')) + db.listPendingMigrateBundles.mockResolvedValue([bundleGrant()]) + const { reconcileMigrateBundles } = await load() + expect(await reconcileMigrateBundles(payment as never, now)).toMatchObject({ alarms: 1 }) + }) + }) +}) diff --git a/tests/unit/migrate-grant-routes.test.ts b/tests/unit/migrate-grant-routes.test.ts index 4265b8bf..eecd90fc 100644 --- a/tests/unit/migrate-grant-routes.test.ts +++ b/tests/unit/migrate-grant-routes.test.ts @@ -132,6 +132,11 @@ describe('Migrate grant routes', () => { expect(db.saveMigrateCommentsExport).not.toHaveBeenCalled() }) + it('refuses an order that was bought as a bundle: its Studio year is on the order, there is no trial to claim', async () => { + db.claimMigrateGrant!.mockResolvedValue({ grant: { ...grantRow, kind: 'bundle', trial_days: null, repo_owner: null, repo_name: null }, created: false }) + await expect((await claimRoute())({} as never)).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_bundle' }) + }) + it('refuses an order another account already claimed', async () => { db.claimMigrateGrant!.mockResolvedValue({ grant: { ...grantRow, user_id: 'user-2' }, created: false }) await expect((await claimRoute())({} as never)).rejects.toMatchObject({ statusCode: 409, message: 'migrate.claim_taken' }) @@ -210,6 +215,13 @@ describe('Migrate grant routes', () => { expect(createCheckoutSession).not.toHaveBeenCalled() }) + it('never opens an included trial for a bundle grant (it is paid through Migrate\'s checkout)', async () => { + db.getMigrateGrantForUser!.mockResolvedValue({ ...grantRow, kind: 'bundle', trial_days: null, repo_owner: null, repo_name: null }) + await expect((await checkoutRoute())({} as never)).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_bundle' }) + expect(db.bindMigrateGrantWorkspace).not.toHaveBeenCalled() + expect(createCheckoutSession).not.toHaveBeenCalled() + }) + it('keeps a grant on the workspace it was first opened for', async () => { db.getMigrateGrantForUser!.mockResolvedValue({ ...grantRow, workspace_id: 'ws-other', bound_at: '2026-09-23T12:00:00Z' }) await expect((await checkoutRoute())({} as never)).rejects.toMatchObject({ statusCode: 409, message: 'migrate.grant_bound_elsewhere' }) diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts new file mode 100644 index 00000000..2772dd0c --- /dev/null +++ b/tests/unit/migrate-provision.test.ts @@ -0,0 +1,308 @@ +import { exportSPKI, generateKeyPair, SignJWT } from 'jose' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +function createErrorLike(input: { statusCode: number, message: string }) { + return Object.assign(new Error(input.message), input) +} + +const resolveMigrateAccountState = vi.fn() +vi.mock('../../server/utils/migrate-account-state', () => ({ + resolveMigrateAccountState: (...args: unknown[]) => resolveMigrateAccountState(...args), +})) +const planSource = { value: 'subscription' } +vi.mock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: planSource.value }) })) + +const NOW = new Date('2026-10-10T12:00:00Z') +const nowSec = Math.floor(NOW.getTime() / 1000) + +const claim = (overrides: Record = {}) => ({ + iss: 'contentrain-migrate', + aud: 'contentrain-studio', + v: 2, + jti: 'jti-1', + iat: nowSec, + exp: nowSec + 300, + sub: 'ten_1', + order_id: 'ord_1', + email: 'owner@example.com', + plan: 'pro', + plan_evidence: [], + github_user_id: '4242', + email_verified: true, + return_url: 'https://migrate.contentrain.io/orders/ord_1?studio=done', + billing: { migrate_fee_cents: 24900, quoted_total_cents: 64100, currency: 'usd' }, + origin: 'https://old-blog.example', + ...overrides, +}) + +const user = { id: 'user-1', email: 'owner@example.com' } +const workspace = { id: 'ws-1', slug: 'owner-abc', name: 'Owner' } +const bundleRow = (overrides: Record = {}) => ({ + id: 'grant-1', order_id: 'ord_1', user_id: 'user-1', kind: 'bundle', plan: 'pro', + redeemed_at: null, bound_at: null, workspace_id: null, + checkout_url: null, checkout_expires_at: null, amount_cents: null, + ...overrides, +}) + +describe('provisionMigrateBundle', () => { + let db: Record> + let auth: { ensureUserForProviderAccount: ReturnType } + let payment: { createBundleCheckout: ReturnType } + + beforeEach(() => { + vi.resetModules() + resolveMigrateAccountState.mockReset().mockResolvedValue({ state: 'none', plan: 'pro', year1_cents: 39200 }) + db = { + listOwnedWorkspacesAdmin: vi.fn().mockResolvedValue([{ id: 'ws-other', type: 'team' }, { id: 'ws-1', type: 'primary' }]), + getWorkspaceById: vi.fn().mockResolvedValue(workspace), + getActivePaymentAccount: vi.fn().mockResolvedValue(null), + claimMigrateGrant: vi.fn().mockResolvedValue({ grant: bundleRow(), created: true }), + bindMigrateGrantWorkspace: vi.fn().mockResolvedValue(bundleRow({ workspace_id: 'ws-1', bound_at: '2026-10-10T12:00:00Z' })), + saveMigrateGrantCheckout: vi.fn().mockResolvedValue(undefined), + } + auth = { ensureUserForProviderAccount: vi.fn().mockResolvedValue(user) } + payment = { + createBundleCheckout: vi.fn().mockResolvedValue({ + url: 'https://sandbox.polar.sh/checkout/c_1', + sessionId: 'co_1', + expiresAt: '2026-10-10T13:00:00.000Z', + targetProductId: 'prod_pro_y', + }), + } + vi.stubGlobal('createError', createErrorLike) + vi.stubGlobal('errorMessage', vi.fn((key: string) => key)) + vi.stubGlobal('useDatabaseProvider', () => db) + vi.stubGlobal('useAuthProvider', () => auth) + vi.stubGlobal('usePaymentProvider', () => payment) + vi.stubGlobal('checkRateLimit', vi.fn().mockResolvedValue({ allowed: true })) + vi.stubGlobal('useRuntimeConfig', () => ({ + public: { siteUrl: 'https://studio.example.com' }, + migrate: { origins: 'https://migrate.contentrain.io' }, + })) + }) + + const run = async (c = claim()) => (await import('../../server/utils/migrate-provision')).provisionMigrateBundle(c as never, NOW) + const refused = async (c = claim()) => run(c).then(() => null, (err: { statusCode: number, message: string }) => ({ status: err.statusCode, key: err.message })) + + it('provisions an account with no plan: user, grant bound to its personal workspace, one checkout at the quoted total', async () => { + const response = await run() + + expect(auth.ensureUserForProviderAccount).toHaveBeenCalledWith({ provider: 'github', accountId: '4242', email: 'owner@example.com' }) + expect(db.claimMigrateGrant).toHaveBeenCalledWith(expect.objectContaining({ orderId: 'ord_1', claimJti: 'jti-1', userId: 'user-1', plan: 'pro', kind: 'bundle', origin: 'https://old-blog.example' })) + expect(db.claimMigrateGrant.mock.calls[0]![0]).not.toHaveProperty('trialDays') + expect(db.bindMigrateGrantWorkspace).toHaveBeenCalledWith('grant-1', 'ws-1') + expect(payment.createBundleCheckout).toHaveBeenCalledTimes(1) + expect(payment.createBundleCheckout).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + plan: 'pro', + customerEmail: 'owner@example.com', + amountCents: 64100, + successUrl: 'https://migrate.contentrain.io/orders/ord_1?studio=done', + metadata: { order_id: 'ord_1', tenant_id: 'ten_1', migrate_grant_id: 'grant-1', migrate_bundle: 'true', studio_url: 'https://studio.example.com' }, + }) + expect(db.saveMigrateGrantCheckout).toHaveBeenCalledWith('grant-1', { + checkoutId: 'co_1', + checkoutUrl: 'https://sandbox.polar.sh/checkout/c_1', + checkoutExpiresAt: '2026-10-10T13:00:00.000Z', + amountCents: 64100, + targetProductId: 'prod_pro_y', + }) + expect(response).toEqual({ + grant_id: 'grant-1', + state: 'bound', + plan: 'pro', + workspace_slug: 'owner-abc', + checkout_url: 'https://sandbox.polar.sh/checkout/c_1', + amount_cents: 64100, + checkout_expires_at: Math.floor(new Date('2026-10-10T13:00:00Z').getTime() / 1000), + }) + }) + + it('refuses a quote that is not what Studio computes now (Migrate fee + the Studio line)', async () => { + expect(await refused(claim({ billing: { migrate_fee_cents: 24900, quoted_total_cents: 60000, currency: 'usd' } }))).toEqual({ status: 409, key: 'migrate.quote_changed' }) + resolveMigrateAccountState.mockResolvedValue({ state: 'none', plan: 'pro', year1_cents: 40000 }) + expect(await refused()).toEqual({ status: 409, key: 'migrate.quote_changed' }) + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + expect(auth.ensureUserForProviderAccount).not.toHaveBeenCalled() + }) + + it.each([['covers'], ['too_small']])('refuses an account whose state is %s: those need another flow, never a checkout at the wrong amount', async (state) => { + resolveMigrateAccountState.mockResolvedValue({ state, plan: 'pro', year1_cents: 0, current_plan: 'starter' }) + expect(await refused()).toEqual({ status: 409, key: 'migrate.bundle_state_unsupported' }) + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + expect(db.claimMigrateGrant).not.toHaveBeenCalled() + }) + + it('refuses a return address that is not on the Migrate allowlist, and an unverified email', async () => { + for (const return_url of ['https://evil.example/orders/1', 'https://migrate.contentrain.io.evil.example/x']) { + expect(await refused(claim({ return_url }))).toEqual({ status: 400, key: 'migrate.return_url_not_allowed' }) + } + expect(await refused(claim({ email_verified: false }))).toEqual({ status: 400, key: 'migrate.email_unverified' }) + expect(auth.ensureUserForProviderAccount).not.toHaveBeenCalled() + }) + + it('refuses everything while no allowlist is configured', async () => { + vi.stubGlobal('useRuntimeConfig', () => ({ public: { siteUrl: 'https://studio.example.com' }, migrate: { origins: '' } })) + expect(await refused()).toEqual({ status: 400, key: 'migrate.return_url_not_allowed' }) + }) + + it('refuses a workspace that already pays (a second subscription is never opened)', async () => { + db.getActivePaymentAccount.mockResolvedValue({ subscription_id: 'sub_old', subscription_status: 'trialing' }) + expect(await refused()).toEqual({ status: 409, key: 'billing.subscription_exists' }) + expect(db.claimMigrateGrant).not.toHaveBeenCalled() + db.getActivePaymentAccount.mockResolvedValue({ subscription_id: 'sub_old', subscription_status: 'canceled' }) + expect(await refused()).toBeNull() + }) + + it('refuses an email whose user has another GitHub account', async () => { + // Loaded after the module reset, so it is the class the provision code sees. + const { IdentityConflictError } = await import('../../server/providers/auth') + auth.ensureUserForProviderAccount.mockRejectedValue(new IdentityConflictError()) + expect(await refused()).toEqual({ status: 409, key: 'migrate.identity_conflict' }) + }) + + it('never reuses an order that belongs to another account, was a trial claim, or was paid', async () => { + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ user_id: 'user-2' }), created: false }) + expect(await refused()).toEqual({ status: 409, key: 'migrate.claim_taken' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ kind: 'trial' }), created: false }) + expect(await refused()).toEqual({ status: 409, key: 'migrate.claim_taken' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ redeemed_at: '2026-10-09T00:00:00Z' }), created: false }) + expect(await refused()).toEqual({ status: 409, key: 'migrate.grant_used' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow(), created: false }) + db.bindMigrateGrantWorkspace.mockResolvedValue(null) + expect(await refused()).toEqual({ status: 409, key: 'migrate.grant_bound_elsewhere' }) + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + }) + + describe('a repeated provision for the same order', () => { + const stored = (overrides: Record = {}) => bundleRow({ + workspace_id: 'ws-1', + checkout_url: 'https://sandbox.polar.sh/checkout/c_old', + checkout_expires_at: '2026-10-10T13:00:00.000Z', + amount_cents: 64100, + ...overrides, + }) + + it('returns the checkout the grant already opened while it is payable and the amount is the same', async () => { + db.claimMigrateGrant.mockResolvedValue({ grant: stored(), created: false }) + const response = await run() + expect(response.checkout_url).toBe('https://sandbox.polar.sh/checkout/c_old') + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + expect(db.saveMigrateGrantCheckout).not.toHaveBeenCalled() + }) + + it('opens a fresh one when the stored one is about to expire, expired, or for another amount', async () => { + for (const overrides of [ + { checkout_expires_at: '2026-10-10T12:02:00.000Z' }, + { checkout_expires_at: '2026-10-10T11:00:00.000Z' }, + { amount_cents: 60000 }, + ]) { + payment.createBundleCheckout.mockClear() + db.claimMigrateGrant.mockResolvedValue({ grant: stored(overrides), created: false }) + expect((await run()).checkout_url).toBe('https://sandbox.polar.sh/checkout/c_1') + expect(payment.createBundleCheckout).toHaveBeenCalledTimes(1) + } + }) + }) + + it('turns a provider failure into a clean 502 and stores nothing', async () => { + payment.createBundleCheckout.mockRejectedValue(new Error('polar down')) + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + expect(await refused()).toEqual({ status: 502, key: 'billing.provider_unavailable' }) + expect(db.saveMigrateGrantCheckout).not.toHaveBeenCalled() + errorLog.mockRestore() + }) + + it('answers 429 instead of opening a second checkout in the same moment', async () => { + vi.stubGlobal('checkRateLimit', vi.fn().mockResolvedValue({ allowed: false })) + expect(await refused()).toEqual({ status: 429, key: 'auth.rate_limited' }) + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + }) + + it('never hands Migrate a checkout address that is not Polar\'s', async () => { + payment.createBundleCheckout.mockResolvedValue({ url: 'https://evil.example/checkout/c_1', sessionId: 'co_1', expiresAt: '2026-10-10T13:00:00.000Z', targetProductId: 'prod_pro_y' }) + expect(await refused()).toEqual({ status: 502, key: 'billing.provider_unavailable' }) + }) + + it('takes the personal workspace over the first one it finds', async () => { + await run() + expect(db.getWorkspaceById).toHaveBeenCalledWith('ws-1', 'id, slug, name') + }) +}) + +describe('POST /api/migrate/provision', () => { + let publicPem: string + let privateKey: CryptoKey + let db: Record> + const provisionMigrateBundle = vi.fn() + + beforeAll(async () => { + const pair = await generateKeyPair('EdDSA', { extractable: true }) + privateKey = pair.privateKey + publicPem = await exportSPKI(pair.publicKey) + }) + + const sign = (payload: Record = {}) => { + const iat = Math.floor(Date.now() / 1000) + // `repo` is optional in claim v2 from @contentrain/types after ai#411; 1.43.0 still requires it. + return new SignJWT({ ...claim({ iat, exp: iat + 300, repo: { provider: 'github', owner: 'acme', name: 'blog' } }), ...payload }).setProtectedHeader({ alg: 'EdDSA' }).sign(privateKey) + } + + beforeEach(() => { + vi.resetModules() + provisionMigrateBundle.mockReset().mockResolvedValue({ grant_id: 'grant-1' }) + vi.doMock('../../server/utils/migrate-provision', () => ({ provisionMigrateBundle: (...args: unknown[]) => provisionMigrateBundle(...args) })) + vi.doMock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) })) + db = { + claimMigrateS2sJti: vi.fn().mockResolvedValue(true), + releaseMigrateS2sJti: vi.fn().mockResolvedValue(undefined), + } + vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) + vi.stubGlobal('createError', createErrorLike) + vi.stubGlobal('errorMessage', vi.fn((key: string) => key)) + vi.stubGlobal('useDatabaseProvider', () => db) + vi.stubGlobal('useRuntimeConfig', () => ({ migrate: { claimPublicKey: publicPem } })) + }) + + const call = async (token: unknown) => { + vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ token })) + const handler = (await import('../../server/api/migrate/provision.post')).default as unknown as (event: unknown) => Promise + return handler({}).then(value => ({ value }), (err: { statusCode: number, message: string }) => ({ status: err.statusCode, key: err.message })) + } + + it('verifies the signed claim v2, takes its jti for the provision purpose, and provisions', async () => { + const result = await call(await sign()) + expect(result).toEqual({ value: { grant_id: 'grant-1' } }) + expect(db.claimMigrateS2sJti).toHaveBeenCalledWith('jti-1', 'provision', expect.any(Date)) + expect(provisionMigrateBundle).toHaveBeenCalledWith(expect.objectContaining({ v: 2, order_id: 'ord_1', github_user_id: '4242' })) + }) + + it('is off without Migrate\'s key', async () => { + vi.stubGlobal('useRuntimeConfig', () => ({ migrate: { claimPublicKey: '' } })) + expect(await call(await sign())).toEqual({ status: 404, key: 'migrate.unavailable' }) + }) + + it('refuses a missing token, a bad signature, a v1 claim and a replay', async () => { + expect(await call(undefined)).toEqual({ status: 400, key: 'migrate.s2s_invalid' }) + expect(await call('not.a.jws')).toEqual({ status: 400, key: 'migrate.s2s_invalid' }) + expect(await call(await sign({ v: 1 }))).toEqual({ status: 400, key: 'migrate.s2s_invalid' }) + db.claimMigrateS2sJti.mockResolvedValue(false) + expect(await call(await sign())).toEqual({ status: 409, key: 'migrate.s2s_replayed' }) + expect(provisionMigrateBundle).not.toHaveBeenCalled() + }) + + it('gives the jti back only when Studio itself failed, not when it refused', async () => { + provisionMigrateBundle.mockRejectedValue(Object.assign(new Error('x'), { statusCode: 409 })) + await call(await sign()) + expect(db.releaseMigrateS2sJti).not.toHaveBeenCalled() + + provisionMigrateBundle.mockRejectedValue(Object.assign(new Error('x'), { statusCode: 502 })) + await call(await sign()) + expect(db.releaseMigrateS2sJti).toHaveBeenCalledWith('jti-1') + + db.releaseMigrateS2sJti.mockClear() + provisionMigrateBundle.mockRejectedValue(new Error('boom')) + await call(await sign()) + expect(db.releaseMigrateS2sJti).toHaveBeenCalledWith('jti-1') + }) +}) From a0af19d042fbf4f31e0860bf6a8fa8ef73914d68 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:01:04 +0300 Subject: [PATCH 4/4] feat(migrate): renewal_cents in the account-state answer The cart fine print needs the yearly list price the subscription renews at; Migrate must not compute it. 0 when nothing is added (covers). --- server/utils/migrate-account-state.ts | 17 ++++++++++++----- tests/unit/migrate-account-state.test.ts | 8 ++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/server/utils/migrate-account-state.ts b/server/utils/migrate-account-state.ts index 483e40dd..fad85ab4 100644 --- a/server/utils/migrate-account-state.ts +++ b/server/utils/migrate-account-state.ts @@ -14,7 +14,7 @@ * to an existing trial subscription. */ import type { MigrateAccountStateResponse, MigrateStudioPlan } from '@contentrain/types' -import { bundleUpgradeCents, bundleYear1Cents, planCovers } from '../../shared/utils/migrate-bundle' +import { STUDIO_YEARLY_LIST_CENTS, bundleUpgradeCents, bundleYear1Cents, planCovers } from '../../shared/utils/migrate-bundle' import { resolveWorkspaceBilling } from './workspace-billing' const RUNNING_STATES = new Set(['subscribed', 'past_due', 'canceled']) @@ -35,11 +35,18 @@ export async function highestRunningPlan(userId: string): Promise { +/** + * `renewal_cents` is the yearly list price the subscription renews at after + * the discounted first year (0 when nothing is added). Migrate may not compute + * it; it shows Studio's number. Not in `@contentrain/types` yet — extra key. + */ +export type MigrateAccountStateWithRenewal = MigrateAccountStateResponse & { renewal_cents: number } + +export async function resolveMigrateAccountState(githubUserId: string, plan: MigrateStudioPlan): Promise { const user = await useAuthProvider().getUserByProviderAccount('github', githubUserId) const current = user ? await highestRunningPlan(user.id) : null - if (!current) return { state: 'none', plan, year1_cents: bundleYear1Cents(plan) } - if (planCovers(current, plan)) return { state: 'covers', plan: current, year1_cents: 0, current_plan: current } - return { state: 'too_small', plan, year1_cents: bundleUpgradeCents(plan, current), current_plan: current } + if (!current) return { state: 'none', plan, year1_cents: bundleYear1Cents(plan), renewal_cents: STUDIO_YEARLY_LIST_CENTS[plan] } + if (planCovers(current, plan)) return { state: 'covers', plan: current, year1_cents: 0, renewal_cents: 0, current_plan: current } + return { state: 'too_small', plan, year1_cents: bundleUpgradeCents(plan, current), renewal_cents: STUDIO_YEARLY_LIST_CENTS[plan], current_plan: current } } diff --git a/tests/unit/migrate-account-state.test.ts b/tests/unit/migrate-account-state.test.ts index 885e9b9d..79989115 100644 --- a/tests/unit/migrate-account-state.test.ts +++ b/tests/unit/migrate-account-state.test.ts @@ -149,26 +149,26 @@ describe('POST /api/migrate/account-state', () => { it('none: no Studio account behind that GitHub user prices year 1 of the sized plan', async () => { auth.getUserByProviderAccount.mockResolvedValue(null) - expect(await ask('pro')).toEqual({ state: 'none', plan: 'pro', year1_cents: 39200 }) + expect(await ask('pro')).toEqual({ state: 'none', plan: 'pro', year1_cents: 39200, renewal_cents: 49000 }) expect(auth.getUserByProviderAccount).toHaveBeenCalledWith('github', '4242') }) it('none: an account without a running paid plan (free workspace, trial) adds the full line', async () => { db.listOwnedWorkspacesAdmin.mockResolvedValue([{ id: 'ws-free', type: 'primary', plan: 'free' }, { id: 'ws-trial', type: 'secondary', plan: 'pro' }]) db.getActivePaymentAccount.mockImplementation(async (id: string) => (id === 'ws-trial' ? { ...account('pro', 'trialing'), trial_ends_at: new Date(Date.now() + 86_400_000).toISOString() } : null)) - expect(await ask('starter')).toEqual({ state: 'none', plan: 'starter', year1_cents: 7200 }) + expect(await ask('starter')).toEqual({ state: 'none', plan: 'starter', year1_cents: 7200, renewal_cents: 9000 }) }) it('covers: a running plan at least the sized one adds nothing and reports the account\'s own plan', async () => { db.listOwnedWorkspacesAdmin.mockResolvedValue([{ id: 'ws-1', type: 'secondary', plan: 'pro' }]) db.getActivePaymentAccount.mockResolvedValue(account('pro')) - expect(await ask('starter')).toEqual({ state: 'covers', plan: 'pro', year1_cents: 0, current_plan: 'pro' }) + expect(await ask('starter')).toEqual({ state: 'covers', plan: 'pro', year1_cents: 0, renewal_cents: 0, current_plan: 'pro' }) }) it('too_small: a running plan below the sized one charges the difference', async () => { db.listOwnedWorkspacesAdmin.mockResolvedValue([{ id: 'ws-1', type: 'secondary', plan: 'starter' }]) db.getActivePaymentAccount.mockResolvedValue(account('starter')) - expect(await ask('pro')).toEqual({ state: 'too_small', plan: 'pro', year1_cents: 32000, current_plan: 'starter' }) + expect(await ask('pro')).toEqual({ state: 'too_small', plan: 'pro', year1_cents: 32000, renewal_cents: 49000, current_plan: 'starter' }) }) it('gives the jti back when our own work fails, so Migrate\'s retry of the same request is taken', async () => {