From 00aed0765c20e57bdde67122a507ae64a9722c65 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:31:21 +0300 Subject: [PATCH 1/5] feat(migrate): provision a covered order onto the running plan W54 covers: when the GitHub account already runs a plan at least the sized one, provision ties the grant to that plan's workspace (the personal one first) and answers redeemed with no checkout. The Studio fee is $0, the quote must equal the Migrate fee, and Polar is not called. A repeat keeps the workspace the grant is tied to. too_small still answers 409 migrate.bundle_state_unsupported until the upgrade flow exists. Needs @contentrain/types 1.47.0 (provision answer without a checkout). --- server/utils/migrate-account-state.ts | 26 +++++++++-- server/utils/migrate-provision.ts | 57 +++++++++++++++++++++--- tests/unit/migrate-account-state.test.ts | 17 +++++++ tests/unit/migrate-provision.test.ts | 53 +++++++++++++++++++++- 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/server/utils/migrate-account-state.ts b/server/utils/migrate-account-state.ts index fad85ab4..885b225a 100644 --- a/server/utils/migrate-account-state.ts +++ b/server/utils/migrate-account-state.ts @@ -19,22 +19,40 @@ import { resolveWorkspaceBilling } from './workspace-billing' const RUNNING_STATES = new Set(['subscribed', 'past_due', 'canceled']) -/** The highest Studio plan, among the user's owned workspaces, that is actually running. */ -export async function highestRunningPlan(userId: string): Promise { +interface RunningPlan { plan: MigrateStudioPlan, workspaceId: string, primary: boolean } + +/** The user's owned workspaces whose plan is actually running, with the sold plan each one holds. */ +async function runningPlans(userId: string): Promise { const db = useDatabaseProvider() const workspaces = await db.listOwnedWorkspacesAdmin(userId) - let best: MigrateStudioPlan | null = null + const running: RunningPlan[] = [] for (const workspace of workspaces) { const billing = await resolveWorkspaceBilling(db, { ...workspace, id: String(workspace.id) }) if (!RUNNING_STATES.has(billing.state)) continue const plan = billing.effectivePlan // Enterprise is above everything Migrate sells. const sold: MigrateStudioPlan | null = plan === 'enterprise' || plan === 'pro' ? 'pro' : plan === 'starter' ? 'starter' : null - if (sold && (!best || planCovers(sold, best))) best = sold + if (sold) running.push({ plan: sold, workspaceId: String(workspace.id), primary: workspace.type === 'primary' }) } + return running +} + +/** The highest Studio plan, among the user's owned workspaces, that is actually running. */ +export async function highestRunningPlan(userId: string): Promise { + let best: MigrateStudioPlan | null = null + for (const { plan } of await runningPlans(userId)) if (!best || planCovers(plan, best)) best = plan return best } +/** The workspace a covered order joins: the account's personal workspace if its plan covers `needed`, else the first owned one that does. */ +export async function coveringWorkspace(userId: string, needed: MigrateStudioPlan): Promise<{ id: string, slug: string } | null> { + const covering = (await runningPlans(userId)).filter(r => planCovers(r.plan, needed)) + const chosen = covering.find(r => r.primary) ?? covering[0] + if (!chosen) return null + const row = await useDatabaseProvider().getWorkspaceById(chosen.workspaceId, 'id, slug') + return row ? { id: String(row.id), slug: String(row.slug) } : null +} + /** * `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 diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index 47371876..15f89e98 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -10,10 +10,11 @@ * * - 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; + * - `none` opens the checkout. `covers` (a running plan at least the sized one) + * opens none: the grant is tied to that plan's workspace and answers `redeemed`, + * the Studio fee is $0 and Polar is not called. `too_small` (an upgrade) is not + * built yet 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 @@ -23,7 +24,7 @@ import type { MigrateProvisionResponse, MigrateStudioClaimV2 } from '@contentrain/types' import { validateMigrateProvisionResponse } from '@contentrain/types' import { IdentityConflictError } from '../providers/auth' -import { resolveMigrateAccountState } from './migrate-account-state' +import { coveringWorkspace, 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. */ @@ -53,6 +54,48 @@ async function bundleWorkspace(userId: string): Promise<{ id: string, slug: stri return { id: String(row.id), slug: String(row.slug), name: String(row.name) } } +/** A running plan already covers the order: join its workspace, charge Studio nothing, open no checkout. */ +async function provisionCovered(claim: MigrateStudioClaimV2, userId: string): Promise { + const db = useDatabaseProvider() + const workspace = await coveringWorkspace(userId, claim.plan) + // The plan covered a moment ago and no longer does: Migrate re-asks the account state and re-quotes. + if (!workspace) fail(409, 'migrate.quote_changed') + const { grant } = await db.claimMigrateGrant({ + orderId: claim.order_id, + claimJti: claim.jti, + userId, + plan: claim.plan, + email: claim.email, + origin: claim.origin ?? null, + kind: 'bundle', + }) + if (grant.user_id !== userId || grant.kind !== 'bundle') fail(409, 'migrate.claim_taken') + if (grant.revoked_at) fail(409, 'migrate.grant_revoked') + // A grant that was opened with a checkout (the account had no plan then) is not a covered one. + if (grant.checkout_url && !grant.redeemed_at) fail(409, 'migrate.quote_changed') + // A repeat finds the grant already tied to a workspace (its own, or the one the bundle was paid on): keep it. + let target = workspace + if (grant.workspace_id && grant.workspace_id !== workspace.id) { + const tied = await db.getWorkspaceById(String(grant.workspace_id), 'id, slug') + if (!tied) fail(500, 'generic.server_error') + target = { id: String(tied.id), slug: String(tied.slug) } + } + const bound = await db.bindMigrateGrantWorkspace(String(grant.id), target.id) + if (!bound) fail(409, 'migrate.grant_bound_elsewhere') + // No subscription of its own: the customer's plan stays theirs (a later revoke cancels nothing of it). + if (!bound.redeemed_at) await db.markMigrateGrantRedeemed(String(grant.id), null) + + const response: MigrateProvisionResponse = { + grant_id: String(grant.id), + state: 'redeemed', + plan: claim.plan, + workspace_slug: target.slug, + } + if (!validateMigrateProvisionResponse(response, { quoted_total_cents: claim.billing.quoted_total_cents }).ok) + fail(502, 'billing.provider_unavailable') + return response +} + 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. @@ -60,7 +103,7 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D // 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 (account.state === 'too_small') 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 @@ -73,6 +116,8 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D } const db = useDatabaseProvider() + if (account.state === 'covers') return provisionCovered(claim, user.id) + const workspace = await bundleWorkspace(user.id) const existingAccount = await db.getActivePaymentAccount(workspace.id) const status = existingAccount?.subscription_status as string | null | undefined diff --git a/tests/unit/migrate-account-state.test.ts b/tests/unit/migrate-account-state.test.ts index 79989115..6017150f 100644 --- a/tests/unit/migrate-account-state.test.ts +++ b/tests/unit/migrate-account-state.test.ts @@ -171,6 +171,23 @@ describe('POST /api/migrate/account-state', () => { expect(await ask('pro')).toEqual({ state: 'too_small', plan: 'pro', year1_cents: 32000, renewal_cents: 49000, current_plan: 'starter' }) }) + it('coveringWorkspace: the personal workspace if its plan covers, else the first covering one, nothing when none does', async () => { + const { coveringWorkspace } = await import('../../server/utils/migrate-account-state') + db.getWorkspaceById = vi.fn(async (id: string) => ({ id, slug: `slug-${id}` })) + db.listOwnedWorkspacesAdmin.mockResolvedValue([ + { id: 'team', type: 'secondary', plan: 'pro' }, + { id: 'home', type: 'primary', plan: 'pro' }, + { id: 'small', type: 'secondary', plan: 'starter' }, + ]) + db.getActivePaymentAccount.mockImplementation(async (id: string) => account(id === 'small' ? 'starter' : 'pro')) + expect(await coveringWorkspace('user-1', 'pro')).toEqual({ id: 'home', slug: 'slug-home' }) + db.getActivePaymentAccount.mockImplementation(async (id: string) => (id === 'small' ? account('starter') : id === 'team' ? account('pro') : null)) + expect(await coveringWorkspace('user-1', 'pro')).toEqual({ id: 'team', slug: 'slug-team' }) + expect(await coveringWorkspace('user-1', 'starter')).toEqual({ id: 'team', slug: 'slug-team' }) + db.getActivePaymentAccount.mockImplementation(async (id: string) => (id === 'small' ? account('starter') : null)) + expect(await coveringWorkspace('user-1', 'pro')).toBeNull() + }) + 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')) diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index a224ad36..7e844ef9 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -6,8 +6,10 @@ function createErrorLike(input: { statusCode: number, message: string }) { } const resolveMigrateAccountState = vi.fn() +const coveringWorkspace = vi.fn() vi.mock('../../server/utils/migrate-account-state', () => ({ resolveMigrateAccountState: (...args: unknown[]) => resolveMigrateAccountState(...args), + coveringWorkspace: (...args: unknown[]) => coveringWorkspace(...args), })) const planSource = { value: 'subscription' } vi.mock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: planSource.value }) })) @@ -52,6 +54,7 @@ describe('provisionMigrateBundle', () => { beforeEach(() => { vi.resetModules() resolveMigrateAccountState.mockReset().mockResolvedValue({ state: 'none', plan: 'pro', year1_cents: 39200 }) + coveringWorkspace.mockReset().mockResolvedValue({ id: 'ws-paid', slug: 'agency' }) db = { listOwnedWorkspacesAdmin: vi.fn().mockResolvedValue([{ id: 'ws-other', type: 'team' }, { id: 'ws-1', type: 'primary' }]), getWorkspaceById: vi.fn().mockResolvedValue(workspace), @@ -126,13 +129,59 @@ describe('provisionMigrateBundle', () => { 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' }) + it('refuses an account that needs an upgrade (too_small): never a checkout at the wrong amount', async () => { + resolveMigrateAccountState.mockResolvedValue({ state: 'too_small', 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() }) + describe('an account whose running plan covers the order', () => { + const covered = () => claim({ billing: { migrate_fee_cents: 24900, quoted_total_cents: 24900, currency: 'usd' } }) + beforeEach(() => { + resolveMigrateAccountState.mockResolvedValue({ state: 'covers', plan: 'pro', year1_cents: 0, renewal_cents: 0, current_plan: 'pro' }) + db.bindMigrateGrantWorkspace.mockResolvedValue(bundleRow({ workspace_id: 'ws-paid', bound_at: '2026-10-10T12:00:00Z' })) + db.markMigrateGrantRedeemed = vi.fn().mockResolvedValue(undefined) + }) + + it('ties the grant to the plan\'s workspace and answers redeemed: no checkout, no Polar call, Studio fee $0', async () => { + expect(await run(covered())).toEqual({ grant_id: 'grant-1', state: 'redeemed', plan: 'pro', workspace_slug: 'agency' }) + expect(coveringWorkspace).toHaveBeenCalledWith('user-1', 'pro') + expect(db.bindMigrateGrantWorkspace).toHaveBeenCalledWith('grant-1', 'ws-paid') + expect(db.markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-1', null) + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + expect(db.saveMigrateGrantCheckout).not.toHaveBeenCalled() + expect(db.getActivePaymentAccount).not.toHaveBeenCalled() + }) + + it('agrees only a quote equal to the Migrate fee (no Studio line)', async () => { + expect(await refused(claim())).toEqual({ status: 409, key: 'migrate.quote_changed' }) + expect(db.claimMigrateGrant).not.toHaveBeenCalled() + }) + + it('is idempotent, and keeps the workspace the grant is already tied to', async () => { + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ workspace_id: 'ws-1', redeemed_at: '2026-10-10T11:00:00Z' }), created: false }) + db.bindMigrateGrantWorkspace.mockResolvedValue(bundleRow({ workspace_id: 'ws-1', redeemed_at: '2026-10-10T11:00:00Z' })) + expect(await run(covered())).toMatchObject({ state: 'redeemed', workspace_slug: 'owner-abc' }) + expect(db.bindMigrateGrantWorkspace).toHaveBeenCalledWith('grant-1', 'ws-1') + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + }) + + it('asks Migrate to re-quote when the plan stopped covering, and never reuses a grant opened with a checkout, a revoked or a foreign one', async () => { + coveringWorkspace.mockResolvedValue(null) + expect(await refused(covered())).toEqual({ status: 409, key: 'migrate.quote_changed' }) + coveringWorkspace.mockResolvedValue({ id: 'ws-paid', slug: 'agency' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ checkout_url: 'https://sandbox.polar.sh/checkout/c_1' }), created: false }) + expect(await refused(covered())).toEqual({ status: 409, key: 'migrate.quote_changed' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ revoked_at: '2026-10-10T11:00:00Z' }), created: false }) + expect(await refused(covered())).toEqual({ status: 409, key: 'migrate.grant_revoked' }) + db.claimMigrateGrant.mockResolvedValue({ grant: bundleRow({ user_id: 'user-2' }), created: false }) + expect(await refused(covered())).toEqual({ status: 409, key: 'migrate.claim_taken' }) + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + expect(payment.createBundleCheckout).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' }) From 2e55fe1857132914b65bdd4d21882a52ec1f07b5 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:32:08 +0300 Subject: [PATCH 2/5] chore(migrate): types 1.47.0 and a checkout answer that is never redeemed Provision answers are a union now: a checkout answer is never redeemed, so a grant paid in the same moment is refused with 409 migrate.grant_used. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- server/utils/migrate-provision.ts | 4 +++- tests/unit/migrate-provision.test.ts | 6 ++++++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index a6d2c718..f9c49084 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.46.0", + "@contentrain/types": "1.47.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 7bf2d8c1..6d1cae54 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.46.0 - version: 1.46.0 + specifier: 1.47.0 + version: 1.47.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.46.0': - resolution: {integrity: sha512-ZbCBNvcpcnIMzjq7AvD12uW3ZGVuLZY0sDPhkGLme8AsZPw4kB+wFVreLSSb+oJ2Te5regwLyS5fpT4mzy3Pmg==} + '@contentrain/types@1.47.0': + resolution: {integrity: sha512-9UK22vycaa+u5i2EsGG1cJauD4hbBTSMTtc3EkXNG2n6xSGyHD59ABEpXD1j3KLOy1c4mJ9bi1ZUFBN/beWdaw==} '@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.46.0': {} + '@contentrain/types@1.47.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index 5d74b3c6..8404be02 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -139,6 +139,8 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D 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') + // Paid in the same moment: the checkout answer never says redeemed, so refuse rather than hand out a used one. + if (bound.redeemed_at) fail(409, 'migrate.grant_used') const quoted = claim.billing.quoted_total_cents const storedExpires = grant.checkout_expires_at ? new Date(String(grant.checkout_expires_at)) : null @@ -186,7 +188,7 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D const response: MigrateProvisionResponse = { grant_id: String(grant.id), - state: bound.redeemed_at ? 'redeemed' : 'bound', + state: 'bound', plan: claim.plan, workspace_slug: workspace.slug, checkout_url: checkoutUrl as string, diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index 873723f9..c2b67815 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -121,6 +121,12 @@ describe('provisionMigrateBundle', () => { }) }) + it('refuses to answer a checkout for a grant that was paid in the same moment', async () => { + db.bindMigrateGrantWorkspace.mockResolvedValue(bundleRow({ workspace_id: 'ws-1', redeemed_at: '2026-10-10T12:00:00Z' })) + expect(await refused()).toEqual({ status: 409, key: 'migrate.grant_used' }) + expect(payment.createBundleCheckout).not.toHaveBeenCalled() + }) + 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 }) From d5acb569b2bd7a7439eb155c020832c7c388d721 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:22:05 +0300 Subject: [PATCH 3/5] fix(migrate): an ending or past_due plan is not Studio included Only an active plan that is not scheduled to end covers a Migrate order. Ending, past_due and canceled plans fall to none and pay the normal bundle, as attach already does. The bundle workspace now skips any workspace that still holds a subscription, so a second one is never opened on a plan that is ending; when every owned workspace holds one, provision refuses with billing.subscription_exists. --- server/utils/migrate-account-state.ts | 13 ++++++------ server/utils/migrate-provision.ts | 26 +++++++++++++++++------- tests/unit/migrate-account-state.test.ts | 14 +++++++++++++ tests/unit/migrate-provision.test.ts | 13 ++++++++++++ 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/server/utils/migrate-account-state.ts b/server/utils/migrate-account-state.ts index 885b225a..45e8e6bc 100644 --- a/server/utils/migrate-account-state.ts +++ b/server/utils/migrate-account-state.ts @@ -8,17 +8,15 @@ * - `covers`: a running plan at least the sized one → nothing is added. * - `too_small`: a running plan below it → the upgrade difference. * - * "Running" is a subscription that is paid up or still inside its paid - * period (`subscribed`, `past_due`, `canceled`). A trial or a locked - * workspace counts as no plan; the provision step (S3) settles what happens + * "Running" is an active subscription that is not ending: `subscribed` without + * `cancel_at_period_end`. past_due, canceled, ending, a trial or a locked + * workspace count as no plan (they get the normal bundle checkout); the provision step (S3) settles what happens * to an existing trial subscription. */ import type { MigrateAccountStateResponse, MigrateStudioPlan } from '@contentrain/types' 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']) - interface RunningPlan { plan: MigrateStudioPlan, workspaceId: string, primary: boolean } /** The user's owned workspaces whose plan is actually running, with the sold plan each one holds. */ @@ -28,7 +26,10 @@ async function runningPlans(userId: string): Promise { const running: RunningPlan[] = [] for (const workspace of workspaces) { const billing = await resolveWorkspaceBilling(db, { ...workspace, id: String(workspace.id) }) - if (!RUNNING_STATES.has(billing.state)) continue + // Only a plan that will still be there next period covers: past_due, canceled and a plan scheduled to end + // (`cancel_at_period_end`) are not "Studio included" — their owner pays the normal bundle (same rule as attach). + if (billing.state !== 'subscribed') continue + if ((await db.getActivePaymentAccount(String(workspace.id)))?.cancel_at_period_end === true) continue const plan = billing.effectivePlan // Enterprise is above everything Migrate sells. const sold: MigrateStudioPlan | null = plan === 'enterprise' || plan === 'pro' ? 'pro' : plan === 'starter' ? 'starter' : null diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index 8404be02..e49b4046 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -43,12 +43,25 @@ export function isAllowedReturnUrl(returnUrl: string, origins: string[]): boolea } } -/** 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 }> { +/** A workspace that still carries a subscription (even one scheduled to end) cannot take a second one. */ +const hasLiveSubscription = (account: Record | null | undefined) => { + const status = account?.subscription_status as string | null | undefined + return Boolean(account?.subscription_id && status && !['canceled', 'incomplete_expired'].includes(status)) +} + +/** The workspace the bundle's subscription belongs on: the account's personal one, else the first, skipping any that already holds a subscription. Null when every owned workspace does. */ +async function bundleWorkspace(userId: string): Promise<{ id: string, slug: string, name: string } | null> { 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') }) + if (!owned.length) throw createError({ statusCode: 500, message: errorMessage('generic.server_error') }) + const ordered = [...owned.filter(w => w.type === 'primary'), ...owned.filter(w => w.type !== 'primary')] + let chosen: (typeof owned)[number] | undefined + for (const w of ordered) { + if (hasLiveSubscription(await db.getActivePaymentAccount(String(w.id)))) continue + chosen = w + break + } + if (!chosen) return null 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) } @@ -118,10 +131,9 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D const db = useDatabaseProvider() if (account.state === 'covers') return provisionCovered(claim, user.id) + // No owned workspace is free of a subscription (e.g. the only one is on a plan that is ending): never a second one on it. 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') + if (!workspace) fail(409, 'billing.subscription_exists') const { grant } = await db.claimMigrateGrant({ orderId: claim.order_id, diff --git a/tests/unit/migrate-account-state.test.ts b/tests/unit/migrate-account-state.test.ts index 6017150f..a52d3178 100644 --- a/tests/unit/migrate-account-state.test.ts +++ b/tests/unit/migrate-account-state.test.ts @@ -165,6 +165,20 @@ describe('POST /api/migrate/account-state', () => { expect(await ask('starter')).toEqual({ state: 'covers', plan: 'pro', year1_cents: 0, renewal_cents: 0, current_plan: 'pro' }) }) + it('none: a plan that is ending (cancel_at_period_end) is not Studio included — the normal bundle applies', async () => { + db.listOwnedWorkspacesAdmin.mockResolvedValue([{ id: 'ws-1', type: 'primary', plan: 'pro' }]) + db.getActivePaymentAccount.mockResolvedValue({ ...account('pro'), cancel_at_period_end: true }) + expect(await ask('starter')).toMatchObject({ state: 'none', plan: 'starter', year1_cents: 7200 }) + }) + + it('none: a past_due or canceled plan is not Studio included either', async () => { + db.listOwnedWorkspacesAdmin.mockResolvedValue([{ id: 'ws-1', type: 'primary', plan: 'pro' }]) + for (const status of ['past_due', 'canceled']) { + db.getActivePaymentAccount.mockResolvedValue(account('pro', status)) + expect(await ask('starter')).toMatchObject({ state: 'none', plan: 'starter' }) + } + }) + 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')) diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index c2b67815..8d24db1f 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -209,6 +209,19 @@ describe('provisionMigrateBundle', () => { expect(await refused()).toBeNull() }) + it('a plan that is ending keeps its workspace: the bundle goes to another owned workspace without a subscription, never a second one on it', async () => { + db.getActivePaymentAccount.mockImplementation(async (id: string) => (id === 'ws-1' ? { subscription_id: 'sub_ending', subscription_status: 'active', cancel_at_period_end: true } : null)) + db.getWorkspaceById.mockImplementation(async (id: string) => ({ id, slug: id, name: id })) + expect(await refused()).toBeNull() + expect(db.bindMigrateGrantWorkspace).toHaveBeenCalledWith(expect.anything(), 'ws-other') + }) + + it('refuses when every owned workspace already holds a subscription, ending ones included', async () => { + db.getActivePaymentAccount.mockResolvedValue({ subscription_id: 'sub_ending', subscription_status: 'active', cancel_at_period_end: true }) + expect(await refused()).toEqual({ status: 409, key: 'billing.subscription_exists' }) + expect(db.claimMigrateGrant).not.toHaveBeenCalled() + }) + 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') From d10fe23d0a4b14242d02eb9d4a180ebbb385a28f Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:24:49 +0300 Subject: [PATCH 4/5] fix(migrate): stable code on the no-free-workspace refusal Provision answers 409 with data.code subscription_exists when every owned workspace already holds a subscription, so Migrate can show the customer the way out without matching localised text. --- server/utils/migrate-provision.ts | 3 ++- tests/unit/migrate-provision.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index e49b4046..881bc8c2 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -133,7 +133,8 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D // No owned workspace is free of a subscription (e.g. the only one is on a plan that is ending): never a second one on it. const workspace = await bundleWorkspace(user.id) - if (!workspace) fail(409, 'billing.subscription_exists') + // `data.code` is the stable handle Migrate matches on (the message is localised text): it shows the customer the way out. + if (!workspace) throw createError({ statusCode: 409, message: errorMessage('billing.subscription_exists'), data: { code: 'subscription_exists' } }) const { grant } = await db.claimMigrateGrant({ orderId: claim.order_id, diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index 8d24db1f..274b7699 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -219,6 +219,9 @@ describe('provisionMigrateBundle', () => { it('refuses when every owned workspace already holds a subscription, ending ones included', async () => { db.getActivePaymentAccount.mockResolvedValue({ subscription_id: 'sub_ending', subscription_status: 'active', cancel_at_period_end: true }) expect(await refused()).toEqual({ status: 409, key: 'billing.subscription_exists' }) + // The answer carries a stable code (the message is localised text) so Migrate can offer the way out. + const error = await run(claim()).catch((e: { data?: unknown }) => e) + expect((error as { data?: unknown }).data).toEqual({ code: 'subscription_exists' }) expect(db.claimMigrateGrant).not.toHaveBeenCalled() }) From f439741d150c4f48b2e4a532d33f4ae74ebb307a Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:34:02 +0300 Subject: [PATCH 5/5] feat(migrate): the no-free-workspace refusal names the workspace to resume The 409 subscription_exists answer carries workspace_slug, the personal workspace first, so Migrate can link to that workspace billing page. Only a workspace the caller owns, and only in the S2S answer. --- server/utils/migrate-provision.ts | 25 ++++++++++++++++++------- tests/unit/migrate-provision.test.ts | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/server/utils/migrate-provision.ts b/server/utils/migrate-provision.ts index 881bc8c2..9ab117b3 100644 --- a/server/utils/migrate-provision.ts +++ b/server/utils/migrate-provision.ts @@ -49,8 +49,12 @@ const hasLiveSubscription = (account: Record | null | undefined return Boolean(account?.subscription_id && status && !['canceled', 'incomplete_expired'].includes(status)) } -/** The workspace the bundle's subscription belongs on: the account's personal one, else the first, skipping any that already holds a subscription. Null when every owned workspace does. */ -async function bundleWorkspace(userId: string): Promise<{ id: string, slug: string, name: string } | null> { +/** + * The workspace the bundle's subscription belongs on: the account's personal one, else the first, skipping any that already + * holds a subscription. When every owned workspace does, `blockedSlug` is the slug of the first of them (the personal one + * first) — where the customer resumes or manages that plan. + */ +async function bundleWorkspace(userId: string): Promise<{ workspace: { id: string, slug: string, name: string } } | { blockedSlug: string | null }> { const db = useDatabaseProvider() const owned = await db.listOwnedWorkspacesAdmin(userId) if (!owned.length) throw createError({ statusCode: 500, message: errorMessage('generic.server_error') }) @@ -61,10 +65,13 @@ async function bundleWorkspace(userId: string): Promise<{ id: string, slug: stri chosen = w break } - if (!chosen) return null + if (!chosen) { + const blocked = await db.getWorkspaceById(String(ordered[0]!.id), 'id, slug') + return { blockedSlug: blocked ? String(blocked.slug) : null } + } 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) } + return { workspace: { id: String(row.id), slug: String(row.slug), name: String(row.name) } } } /** A running plan already covers the order: join its workspace, charge Studio nothing, open no checkout. */ @@ -132,9 +139,13 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D if (account.state === 'covers') return provisionCovered(claim, user.id) // No owned workspace is free of a subscription (e.g. the only one is on a plan that is ending): never a second one on it. - const workspace = await bundleWorkspace(user.id) - // `data.code` is the stable handle Migrate matches on (the message is localised text): it shows the customer the way out. - if (!workspace) throw createError({ statusCode: 409, message: errorMessage('billing.subscription_exists'), data: { code: 'subscription_exists' } }) + // `data.code` is the stable handle Migrate matches on (the message is localised text); `workspace_slug` (a workspace the + // caller owns, S2S only) is where Migrate sends the customer to resume the plan. + const picked = await bundleWorkspace(user.id) + if ('blockedSlug' in picked) { + throw createError({ statusCode: 409, message: errorMessage('billing.subscription_exists'), data: { code: 'subscription_exists', ...(picked.blockedSlug ? { workspace_slug: picked.blockedSlug } : {}) } }) + } + const { workspace } = picked const { grant } = await db.claimMigrateGrant({ orderId: claim.order_id, diff --git a/tests/unit/migrate-provision.test.ts b/tests/unit/migrate-provision.test.ts index 274b7699..c6b4bab4 100644 --- a/tests/unit/migrate-provision.test.ts +++ b/tests/unit/migrate-provision.test.ts @@ -221,7 +221,7 @@ describe('provisionMigrateBundle', () => { expect(await refused()).toEqual({ status: 409, key: 'billing.subscription_exists' }) // The answer carries a stable code (the message is localised text) so Migrate can offer the way out. const error = await run(claim()).catch((e: { data?: unknown }) => e) - expect((error as { data?: unknown }).data).toEqual({ code: 'subscription_exists' }) + expect((error as { data?: unknown }).data).toEqual({ code: 'subscription_exists', workspace_slug: 'owner-abc' }) expect(db.claimMigrateGrant).not.toHaveBeenCalled() })