diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 84c2f1b9..d1d4c10c 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -244,17 +244,23 @@ "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.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.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.grant_not_ready": "Studio is not ready for GitHub yet. Finish the Studio plan step in Migrate first.", + "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.install_already": "Studio is already connected to GitHub for this migration.", "migrate.install_state_invalid": "This GitHub connection link is not valid or has expired. Start again from your Migrate page.", + "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.", "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/.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 0d4af55b..0135819b 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 203dae6b..1e630a56 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.42.0", + "@contentrain/types": "1.44.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 39af3a29..9dd49dc6 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.42.0 - version: 1.42.0 + specifier: 1.44.0 + version: 1.44.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.42.0': - resolution: {integrity: sha512-/HHbY4vEaqRBBJCzpojziVeJCUsQNLDc1FGaYlSO/0p4xfBsNJPjRsvBxlC+EpIpjdkkj6AYOPO3uA98OGOcYg==} + '@contentrain/types@1.44.0': + resolution: {integrity: sha512-77d1Sf5rUKd65MxtINltBvs9wkH7z7m6NnUkbM3xH6dhiytfwRtXAWJvozgk4u0qgmU0btPanc6uZKx2OxB2MQ==} '@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.42.0': {} + '@contentrain/types@1.44.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: 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/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/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/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 960a7d93..91bc4127 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 by the Migrate order it belongs to (one per order); null when Studio holds none. For Migrate's server-to-server calls, which name an order and no user. */ getMigrateGrantByOrderId: (orderId: string) => Promise @@ -1149,6 +1175,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..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' @@ -165,6 +166,7 @@ async function upsertOAuthUser(input: { }) .where('id', '=', existingId) .execute() + await recordIdentity(existingId, input.provider, input.providerAccountId, now) return (await loadUserById(existingId))! } @@ -181,9 +183,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,16 +587,58 @@ 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 }, + 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 ec3d5707..a4b5aa84 100644 --- a/server/providers/postgres-db/migrate-grants.ts +++ b/server/providers/postgres-db/migrate-grants.ts @@ -10,12 +10,17 @@ import { getAdmin, throwDbError } from './helpers' type MigrateGrantMethods = Pick< DatabaseProvider, | 'claimMigrateGrant' + | 'getMigrateGrantById' + | 'saveMigrateGrantCheckout' + | 'markMigrateBundleApplied' + | 'listPendingMigrateBundles' | 'getMigrateGrantByOrderId' | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' | 'getMigrateGrantOrigin' | 'claimMigrateS2sJti' + | 'releaseMigrateS2sJti' | 'listOwnedWorkspacesAdmin' | 'saveMigrateCommentsExport' | 'getMigrateCommentsExport' @@ -64,6 +69,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') @@ -82,11 +91,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() @@ -114,6 +124,20 @@ 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 getMigrateGrantByOrderId(orderId) { try { const row = await getAdmin() @@ -128,6 +152,57 @@ export function migrateGrantMethods(): MigrateGrantMethods { } }, + 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 3e15dd0e..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 } @@ -581,6 +590,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 +688,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..60ab2749 100644 --- a/server/providers/supabase-auth.ts +++ b/server/providers/supabase-auth.ts @@ -257,17 +257,32 @@ 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 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 { diff --git a/server/providers/supabase-db/migrate-grants.ts b/server/providers/supabase-db/migrate-grants.ts index 371c8c09..1e2d55ce 100644 --- a/server/providers/supabase-db/migrate-grants.ts +++ b/server/providers/supabase-db/migrate-grants.ts @@ -10,12 +10,17 @@ import { getAdmin } from './helpers' type MigrateGrantMethods = Pick< DatabaseProvider, | 'claimMigrateGrant' + | 'getMigrateGrantById' + | 'saveMigrateGrantCheckout' + | 'markMigrateBundleApplied' + | 'listPendingMigrateBundles' | 'getMigrateGrantByOrderId' | 'getMigrateGrantForUser' | 'bindMigrateGrantWorkspace' | 'markMigrateGrantRedeemed' | 'getMigrateGrantOrigin' | 'claimMigrateS2sJti' + | 'releaseMigrateS2sJti' | 'listOwnedWorkspacesAdmin' | 'saveMigrateCommentsExport' | 'getMigrateCommentsExport' @@ -79,6 +84,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') @@ -97,11 +107,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) @@ -126,6 +137,12 @@ 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 getMigrateGrantByOrderId(orderId) { const { data, error } = await getAdmin() .from('migrate_grants') @@ -136,6 +153,43 @@ export function migrateGrantMethods(): MigrateGrantMethods { 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-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/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/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/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 ba757159..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({ @@ -115,6 +155,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 3ad7e53a..8d6a6ee5 100644 --- a/tests/contract/migrate-grants.contract.test.ts +++ b/tests/contract/migrate-grants.contract.test.ts @@ -106,6 +106,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() @@ -192,6 +250,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/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-account-state.test.ts b/tests/unit/migrate-account-state.test.ts index ecd2eae4..79989115 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') @@ -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), } @@ -148,26 +149,34 @@ 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 () => { + 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 () => { 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..a224ad36 --- /dev/null +++ b/tests/unit/migrate-provision.test.ts @@ -0,0 +1,315 @@ +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 (@contentrain/types 1.44.0): the bundle opens before the delivery repo exists. + return new SignJWT({ ...claim({ iat, exp: iat + 300 }), ...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('accepts a claim with no repo and one that names it', async () => { + expect(await call(await sign())).toEqual({ value: { grant_id: 'grant-1' } }) + expect(provisionMigrateBundle.mock.calls[0]![0]).not.toHaveProperty('repo') + await call(await sign({ jti: 'jti-2', repo: { provider: 'github', owner: 'acme', name: 'blog' } })) + expect(provisionMigrateBundle.mock.calls[1]![0]).toHaveProperty('repo') + }) + + 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') + }) +})