Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 28 additions & 9 deletions server/utils/migrate-account-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,52 @@
* - `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 highest Studio plan, among the user's owned workspaces, that is actually running. */
export async function highestRunningPlan(userId: string): Promise<MigrateStudioPlan | null> {
/** The user's owned workspaces whose plan is actually running, with the sold plan each one holds. */
async function runningPlans(userId: string): Promise<RunningPlan[]> {
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
// 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
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<MigrateStudioPlan | null> {
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
Expand Down
103 changes: 87 additions & 16 deletions server/utils/migrate-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -42,15 +43,77 @@ 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<string, unknown> | 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. 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)
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) {
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. */
async function provisionCovered(claim: MigrateStudioClaimV2, userId: string): Promise<MigrateProvisionResponse> {
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<MigrateProvisionResponse> {
Expand All @@ -60,7 +123,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
Expand All @@ -73,10 +136,16 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D
}

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')
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.
// `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,
Expand All @@ -94,6 +163,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
Expand Down Expand Up @@ -141,7 +212,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,
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/migrate-account-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,43 @@ 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'))
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'))
Expand Down
Loading
Loading