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
10 changes: 7 additions & 3 deletions server/utils/migrate-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const hasLiveSubscription = (account: Record<string, unknown> | null | undefined
* 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 }> {
async function bundleWorkspace(userId: string): Promise<{ workspace: { id: string, slug: string, name: string } } | { blockedSlug: string | null, pastDue: boolean }> {
const db = useDatabaseProvider()
const owned = await db.listOwnedWorkspacesAdmin(userId)
if (!owned.length) throw createError({ statusCode: 500, message: errorMessage('generic.server_error') })
Expand All @@ -67,7 +67,9 @@ async function bundleWorkspace(userId: string): Promise<{ workspace: { id: strin
}
if (!chosen) {
const blocked = await db.getWorkspaceById(String(ordered[0]!.id), 'id, slug')
return { blockedSlug: blocked ? String(blocked.slug) : null }
// The workspace the customer is sent to is the first one; whether its payment failed decides which way out Migrate offers.
const pastDue = (await db.getActivePaymentAccount(String(ordered[0]!.id)))?.subscription_status === 'past_due'
return { blockedSlug: blocked ? String(blocked.slug) : null, pastDue }
}
const row = await db.getWorkspaceById(String(chosen.id), 'id, slug, name')
if (!row) throw createError({ statusCode: 500, message: errorMessage('generic.server_error') })
Expand Down Expand Up @@ -143,7 +145,9 @@ export async function provisionMigrateBundle(claim: MigrateStudioClaimV2, now: D
// 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 } : {}) } })
// A subscription whose payment failed is its own answer (`subscription_past_due`): the way out is to fix the payment, not to resume a plan.
const code = picked.pastDue ? 'subscription_past_due' : 'subscription_exists'
throw createError({ statusCode: 409, message: errorMessage(picked.pastDue ? 'migrate.attach_past_due' : 'billing.subscription_exists'), data: { code, ...(picked.blockedSlug ? { workspace_slug: picked.blockedSlug } : {}) } })
}
const { workspace } = picked

Expand Down
8 changes: 8 additions & 0 deletions tests/unit/migrate-provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@ describe('provisionMigrateBundle', () => {
expect(db.claimMigrateGrant).not.toHaveBeenCalled()
})

it('a subscription whose payment failed has its own code, so Migrate can say so instead of "ending"', async () => {
db.getActivePaymentAccount.mockResolvedValue({ subscription_id: 'sub_late', subscription_status: 'past_due' })
expect(await refused()).toEqual({ status: 409, key: 'migrate.attach_past_due' })
const error = await run(claim()).catch((e: { data?: unknown }) => e)
expect((error as { data?: unknown }).data).toEqual({ code: 'subscription_past_due', workspace_slug: 'owner-abc' })
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')
Expand Down
Loading