From 9920034c348ff369dd2ce4f333805c79043acab1 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:25:27 +0300 Subject: [PATCH 1/2] feat(migrate): add the delivered site to a workspace whose plan covers it W53: the claim screen locked every workspace that already paid, leaving the customer with a disabled button. A workspace whose running plan covers the grant's plan now offers "Add the site to this plan": POST /api/migrate/grants/:grantId/attach ties the grant to the workspace and marks it used with no subscription of its own, so no trial and no second subscription start. A plan below the grant's shows why and links to billing settings; a trialing or ending plan is refused with a reason. --- .../content/system/error-messages/en.json | 2 + .../content/system/ui-strings/en.json | 8 +- app/pages/migrate/claim.vue | 59 ++++++++++-- .../migrate/grants/[grantId]/attach.post.ts | 59 ++++++++++++ tests/unit/migrate-grant-attach.test.ts | 89 +++++++++++++++++++ 5 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 server/api/migrate/grants/[grantId]/attach.post.ts create mode 100644 tests/unit/migrate-grant-attach.test.ts diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index d1d4c10c..4c8112cc 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -244,6 +244,8 @@ "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.attach_no_plan": "This workspace has no paid Studio plan running yet. Start the included trial, or wait for its plan to start.", + "migrate.attach_plan_too_small": "This workspace's plan is below the plan your site needs. Upgrade it in billing settings, then add the site.", "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.", diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 55d677b3..9a6e203a 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -807,7 +807,10 @@ "members.role_update_success": "Role updated", "members.role_viewer": "Viewer", "members.select_project": "Select project", - "migrate_claim.already_used": "This offer has been used: its included days started on a subscription. You can manage it from the workspace’s billing settings.", + "migrate_claim.already_used": "This offer has been used. You can manage the plan from the workspace’s billing settings.", + "migrate_claim.attach_button": "Add the site to this plan", + "migrate_claim.attach_covers": "Plan covers it", + "migrate_claim.attach_note": "This workspace already pays for a plan that covers your site. The site is added to it: no trial and no second subscription.", "migrate_claim.cancel_note": "The payment provider asks for a card, but charges nothing now. We remind you 7, 3 and 1 day before the first charge, and you can cancel anytime before it.", "migrate_claim.checkout_failed": "We couldn’t open the checkout. Please try again.", "migrate_claim.comments_expired": "The comments export has expired. Upload the export file in the project’s comments settings.", @@ -819,6 +822,7 @@ "migrate_claim.due_today": "Due today", "migrate_claim.heading": "{days} days of Studio {plan}", "migrate_claim.ineligible_bound": "Offer tied to another workspace", + "migrate_claim.ineligible_plan_below": "Plan below {plan}", "migrate_claim.ineligible_role": "Owner or admin only", "migrate_claim.ineligible_subscribed": "Already subscribed", "migrate_claim.kicker": "Included with your migration", @@ -835,6 +839,8 @@ "migrate_claim.then": "After the included days", "migrate_claim.then_price": "{price}/month from {date}", "migrate_claim.title": "Your included Studio", + "migrate_claim.upgrade_hint": "Your plan is below Studio {plan}, which your site needs.", + "migrate_claim.upgrade_link": "Upgrade it in billing settings", "migration.archived": "Archived", "migration.card_title": "Migrated from WordPress", "migration.claim_export_expired": "The comments export from your Migrate order has expired. Upload the export file in the comments settings instead.", diff --git a/app/pages/migrate/claim.vue b/app/pages/migrate/claim.vue index bcf75d12..9db3a0aa 100644 --- a/app/pages/migrate/claim.vue +++ b/app/pages/migrate/claim.vue @@ -12,6 +12,9 @@ * The visitor picks a workspace they own or administer that has no running * subscription; the provider checkout then starts the grant's trial at $0 * today, and the plan's regular price applies after it unless canceled. + * A workspace that already pays for a plan covering the grant's is offered + * too: the site is added to it, with no trial and no second subscription. A + * plan below the grant's is shown with why, and a way to upgrade it. * * Once the grant's trial has started, the screen is also the way back to * the delivered site: its project once the repo is connected there (straight @@ -19,6 +22,7 @@ * Studio"), the workspace until then. */ import { PLAN_PRICING } from '~~/shared/utils/license' +import { planCovers } from '~~/shared/utils/migrate-bundle' definePageMeta({ layout: false, @@ -72,16 +76,36 @@ function hasRunningSubscription(workspace: WorkspaceItem): boolean { return !['canceled', 'incomplete_expired'].includes(account.subscription_status ?? '') } -interface WorkspaceOption { workspace: WorkspaceItem, eligible: boolean, reason: string | null } +interface WorkspaceOption { workspace: WorkspaceItem, eligible: boolean, reason: string | null, attach: boolean, tooSmall?: boolean } + +/** The sold plan behind a workspace's running, paid subscription (Enterprise sits above both). */ +function paidPlanOf(workspace: WorkspaceItem): 'starter' | 'pro' | null { + const account = workspace.payment_account + if (!account || !['active', 'past_due'].includes(account.subscription_status ?? '')) return null + return account.plan === 'pro' || account.plan === 'enterprise' ? 'pro' : account.plan === 'starter' ? 'starter' : null +} const options = computed(() => workspaces.value.map((workspace) => { const role = workspace.workspace_members?.[0]?.role - if (role !== 'owner' && role !== 'admin') return { workspace, eligible: false, reason: t('migrate_claim.ineligible_role') } - if (grant.value?.workspaceId && grant.value.workspaceId !== workspace.id) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_bound') } - if (hasRunningSubscription(workspace)) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_subscribed') } - return { workspace, eligible: true, reason: null } + if (role !== 'owner' && role !== 'admin') return { workspace, eligible: false, reason: t('migrate_claim.ineligible_role'), attach: false } + if (grant.value?.workspaceId && grant.value.workspaceId !== workspace.id) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_bound'), attach: false } + if (hasRunningSubscription(workspace)) { + const paid = paidPlanOf(workspace) + const needed = grant.value?.plan + if (paid && needed && planCovers(paid, needed)) return { workspace, eligible: true, reason: t('migrate_claim.attach_covers'), attach: true } + if (paid && needed) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_plan_below', { plan: PLAN_PRICING[needed].name }), attach: false, tooSmall: true } + return { workspace, eligible: false, reason: t('migrate_claim.ineligible_subscribed'), attach: false } + } + return { workspace, eligible: true, reason: null, attach: false } })) +const selectedOption = computed(() => options.value.find(o => o.workspace.id === selectedWorkspaceId.value) ?? null) +/** Every workspace is taken or too small: the way on is upgrading one, not waiting. */ +const upgradeTarget = computed(() => { + if (options.value.some(o => o.eligible)) return null + return options.value.find(o => o.tooSmall)?.workspace ?? null +}) + const trialEndText = computed(() => { if (!grant.value) return '' const end = new Date(Date.now() + grant.value.trialDays * 24 * 60 * 60 * 1000) @@ -120,6 +144,15 @@ async function startTrial() { submitting.value = true submitError.value = '' try { + if (selectedOption.value?.attach) { + await $fetch(`/api/migrate/grants/${grant.value.id}/attach`, { method: 'POST', body: { workspaceId: selectedWorkspaceId.value } }) + // The grant is used now: reload it so the screen shows the way to the site. + const refreshed = await $fetch<{ grant: GrantView, destination?: Destination | null }>(`/api/migrate/grants/${encodeURIComponent(grant.value.id)}`) + grant.value = refreshed.grant + destination.value = refreshed.destination ?? null + submitting.value = false + return + } const result = await $fetch<{ url: string }>(`/api/migrate/grants/${grant.value.id}/checkout`, { method: 'POST', body: { workspaceId: selectedWorkspaceId.value }, @@ -189,7 +222,10 @@ async function startTrial() { diff --git a/server/api/migrate/grants/[grantId]/attach.post.ts b/server/api/migrate/grants/[grantId]/attach.post.ts new file mode 100644 index 00000000..824ca0a8 --- /dev/null +++ b/server/api/migrate/grants/[grantId]/attach.post.ts @@ -0,0 +1,59 @@ +/** + * POST /api/migrate/grants/:grantId/attach + * + * The included-trial grant of a customer who already pays for Studio: the + * delivered site joins a workspace whose running plan covers the grant's + * plan, and no second subscription (and no trial) starts. The grant is tied + * to the workspace and marked used with no subscription of its own, so the + * claim screen goes straight to the site. A plan below the grant's, or one + * still in its trial or ending, is refused with a reason the screen shows; + * the visitor then upgrades the plan, or picks another workspace. + */ +import { migrateClaimPublicKey } from '../../../../utils/migrate-grant' +import { resolveWorkspaceBilling } from '../../../../utils/workspace-billing' +import { planCovers } from '../../../../../shared/utils/migrate-bundle' +import type { MigrateStudioPlan } from '@contentrain/types' + +export default defineEventHandler(async (event) => { + const session = requireAuth(event) + if (!migrateClaimPublicKey()) + throw createError({ statusCode: 404, message: errorMessage('migrate.unavailable') }) + + const grantId = getRouterParam(event, 'grantId') ?? '' + const body = await readBody<{ workspaceId?: unknown }>(event) + const workspaceId = typeof body?.workspaceId === 'string' ? body.workspaceId : '' + if (!grantId || !workspaceId) + throw createError({ statusCode: 400, message: errorMessage('validation.params_required') }) + + const db = useDatabaseProvider() + const grant = await db.getMigrateGrantForUser(grantId, session.user.id) + if (!grant) throw createError({ statusCode: 404, message: errorMessage('migrate.grant_not_found') }) + + const workspace = await db.getWorkspaceForUser( + session.accessToken, + session.user.id, + workspaceId, + ['owner', 'admin'], + 'id, slug, name, type, plan, overage_settings', + ) + if (!workspace) throw createError({ statusCode: 403, message: errorMessage('auth.forbidden') }) + + if (grant.revoked_at) throw createError({ statusCode: 409, message: errorMessage('migrate.grant_revoked') }) + if (grant.redeemed_at) throw createError({ statusCode: 409, message: errorMessage('migrate.grant_used') }) + // A bundle grant is paid through Migrate's checkout; it never attaches. + 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') }) + + const billing = await resolveWorkspaceBilling(db, { ...workspace, id: workspaceId } as Parameters[1]) + if (billing.state !== 'subscribed' && billing.state !== 'past_due') + throw createError({ statusCode: 409, message: errorMessage('migrate.attach_no_plan') }) + const current = billing.effectivePlan === 'enterprise' ? 'pro' : billing.effectivePlan + if ((current !== 'starter' && current !== 'pro') || !planCovers(current as MigrateStudioPlan, grant.plan as MigrateStudioPlan)) + throw createError({ statusCode: 409, message: errorMessage('migrate.attach_plan_too_small') }) + + const bound = await db.bindMigrateGrantWorkspace(grantId, workspaceId) + if (!bound) throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bound_elsewhere') }) + await db.markMigrateGrantRedeemed(grantId, null) + return { ok: true, workspaceSlug: (workspace as { slug: string }).slug } +}) diff --git a/tests/unit/migrate-grant-attach.test.ts b/tests/unit/migrate-grant-attach.test.ts new file mode 100644 index 00000000..756bd09d --- /dev/null +++ b/tests/unit/migrate-grant-attach.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +function createErrorLike(input: { statusCode: number, message: string }) { + return Object.assign(new Error(input.message), input) +} + +vi.mock('../../server/utils/deployment', () => ({ resolveDeployment: () => ({ planSource: 'subscription' }) })) + +const grantRow = { + id: 'grant-1', order_id: 'ord_123', user_id: 'user-1', kind: 'trial', plan: 'pro', trial_days: 60, + workspace_id: null, bound_at: null, redeemed_at: null, revoked_at: null, +} +const account = (over: Record = {}) => ({ + subscription_id: 'sub_1', subscription_status: 'active', plan: 'pro', current_period_end: '2027-01-01T00:00:00Z', + trial_ends_at: null, cancel_at_period_end: false, grace_period_ends_at: null, plugin_metadata: {}, ...over, +}) + +describe('POST /api/migrate/grants/:grantId/attach', () => { + let db: Record> + + const attach = async () => ((await import('../../server/api/migrate/grants/[grantId]/attach.post')).default as (e: unknown) => Promise)({}) + + beforeEach(() => { + vi.resetModules() + db = { + getMigrateGrantForUser: vi.fn().mockResolvedValue(grantRow), + getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1', slug: 'acme', name: 'Acme', type: 'secondary', plan: 'pro', overage_settings: {} }), + getActivePaymentAccount: vi.fn().mockResolvedValue(account()), + bindMigrateGrantWorkspace: vi.fn().mockResolvedValue({ ...grantRow, workspace_id: 'ws-1', bound_at: '2026-10-03T10:00:00Z' }), + markMigrateGrantRedeemed: vi.fn().mockResolvedValue(undefined), + } + vi.stubGlobal('defineEventHandler', (h: unknown) => h) + vi.stubGlobal('createError', createErrorLike) + vi.stubGlobal('errorMessage', (key: string) => key) + vi.stubGlobal('requireAuth', () => ({ user: { id: 'user-1' }, accessToken: 't' })) + vi.stubGlobal('readBody', () => Promise.resolve({ workspaceId: 'ws-1' })) + vi.stubGlobal('getRouterParam', () => 'grant-1') + vi.stubGlobal('useRuntimeConfig', () => ({ migrate: { claimPublicKey: '-----BEGIN PUBLIC KEY-----\\nMCow\\n-----END PUBLIC KEY-----' } })) + vi.stubGlobal('useDatabaseProvider', () => db) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('adds the site to a workspace whose paid plan covers the grant: bound and used, no subscription of its own', async () => { + expect(await attach()).toEqual({ ok: true, workspaceSlug: 'acme' }) + expect(db.bindMigrateGrantWorkspace).toHaveBeenCalledWith('grant-1', 'ws-1') + expect(db.markMigrateGrantRedeemed).toHaveBeenCalledWith('grant-1', null) + }) + + it('treats Enterprise as covering Pro', async () => { + db.getActivePaymentAccount.mockResolvedValue(account({ plan: 'enterprise' })) + db.getWorkspaceForUser.mockResolvedValue({ id: 'ws-1', slug: 'acme', type: 'secondary', plan: 'enterprise', overage_settings: {} }) + await expect(attach()).resolves.toMatchObject({ ok: true }) + }) + + it('refuses a plan below the grant\'s and changes nothing', async () => { + db.getActivePaymentAccount.mockResolvedValue(account({ plan: 'starter' })) + db.getWorkspaceForUser.mockResolvedValue({ id: 'ws-1', slug: 'acme', type: 'secondary', plan: 'starter', overage_settings: {} }) + await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: 'migrate.attach_plan_too_small' }) + expect(db.bindMigrateGrantWorkspace).not.toHaveBeenCalled() + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + }) + + it('refuses a workspace with no running paid plan (none, or still in trial)', async () => { + db.getActivePaymentAccount.mockResolvedValue(null) + await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: 'migrate.attach_no_plan' }) + db.getActivePaymentAccount.mockResolvedValue(account({ subscription_status: 'trialing', trial_ends_at: '2027-01-01T00:00:00Z' })) + await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: 'migrate.attach_no_plan' }) + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + }) + + it.each([ + [{ redeemed_at: '2026-10-03T10:00:00Z' }, 'migrate.grant_used'], + [{ revoked_at: '2026-10-03T10:00:00Z' }, 'migrate.grant_revoked'], + [{ kind: 'bundle' }, 'migrate.grant_bundle'], + [{ bound_at: '2026-10-03T10:00:00Z', workspace_id: 'ws-2' }, 'migrate.grant_bound_elsewhere'], + ])('refuses grant %o', async (over, key) => { + db.getMigrateGrantForUser.mockResolvedValue({ ...grantRow, ...over }) + await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: key }) + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + }) + + it('needs an owner or admin of the workspace, and a grant of the caller\'s', async () => { + db.getWorkspaceForUser.mockResolvedValue(null) + await expect(attach()).rejects.toMatchObject({ statusCode: 403 }) + db.getMigrateGrantForUser.mockResolvedValue(null) + await expect(attach()).rejects.toMatchObject({ statusCode: 404 }) + }) +}) From 956b6888f0c1b32823b819ba95e9c50274b29d13 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:41:36 +0300 Subject: [PATCH 2/2] fix(migrate): attach only to a paid plan that continues t6 revise on #407: a plan set to end at the period's close (active + cancel_at_period_end) answers 409 migrate.attach_no_plan, and a past_due plan answers 409 migrate.attach_past_due (fix billing first). A grant is a one-time credit; attaching it to a plan about to lapse would take the site's Studio with it. The claim screen hides both options, shows the reason on the row and, when nothing else is selectable, the billing link (too small, ending and overdue each get their own hint). --- .../content/system/error-messages/en.json | 3 ++- .../content/system/ui-strings/en.json | 8 ++++-- app/pages/migrate/claim.vue | 26 +++++++++++-------- .../migrate/grants/[grantId]/attach.post.ts | 12 ++++++--- tests/unit/migrate-grant-attach.test.ts | 14 ++++++++++ 5 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 814dafd2..26b01c8a 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -244,7 +244,8 @@ "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.attach_no_plan": "This workspace has no paid Studio plan running yet. Start the included trial, or wait for its plan to start.", + "migrate.attach_no_plan": "This workspace has no paid Studio plan that continues: it is in a trial, ended or set to end. Renew it in billing settings, or start the included trial on another workspace.", + "migrate.attach_past_due": "This workspace has an unpaid invoice. Fix its billing first, then add the site.", "migrate.attach_plan_too_small": "This workspace's plan is below the plan your site needs. Upgrade it in billing settings, then add the site.", "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.", diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 9a6e203a..00c78ad7 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -811,6 +811,10 @@ "migrate_claim.attach_button": "Add the site to this plan", "migrate_claim.attach_covers": "Plan covers it", "migrate_claim.attach_note": "This workspace already pays for a plan that covers your site. The site is added to it: no trial and no second subscription.", + "migrate_claim.billing_hint_ending": "Your plan is set to end, so the site cannot be added to it.", + "migrate_claim.billing_hint_past_due": "Your plan has an unpaid invoice, so the site cannot be added to it yet.", + "migrate_claim.billing_hint_too_small": "Your plan is below Studio {plan}, which your site needs.", + "migrate_claim.billing_link": "Open billing settings", "migrate_claim.cancel_note": "The payment provider asks for a card, but charges nothing now. We remind you 7, 3 and 1 day before the first charge, and you can cancel anytime before it.", "migrate_claim.checkout_failed": "We couldn’t open the checkout. Please try again.", "migrate_claim.comments_expired": "The comments export has expired. Upload the export file in the project’s comments settings.", @@ -822,6 +826,8 @@ "migrate_claim.due_today": "Due today", "migrate_claim.heading": "{days} days of Studio {plan}", "migrate_claim.ineligible_bound": "Offer tied to another workspace", + "migrate_claim.ineligible_ending": "Plan is ending", + "migrate_claim.ineligible_past_due": "Payment overdue", "migrate_claim.ineligible_plan_below": "Plan below {plan}", "migrate_claim.ineligible_role": "Owner or admin only", "migrate_claim.ineligible_subscribed": "Already subscribed", @@ -839,8 +845,6 @@ "migrate_claim.then": "After the included days", "migrate_claim.then_price": "{price}/month from {date}", "migrate_claim.title": "Your included Studio", - "migrate_claim.upgrade_hint": "Your plan is below Studio {plan}, which your site needs.", - "migrate_claim.upgrade_link": "Upgrade it in billing settings", "migration.archived": "Archived", "migration.card_title": "Migrated from WordPress", "migration.claim_export_expired": "The comments export from your Migrate order has expired. Upload the export file in the comments settings instead.", diff --git a/app/pages/migrate/claim.vue b/app/pages/migrate/claim.vue index 9db3a0aa..02a83b0c 100644 --- a/app/pages/migrate/claim.vue +++ b/app/pages/migrate/claim.vue @@ -76,12 +76,12 @@ function hasRunningSubscription(workspace: WorkspaceItem): boolean { return !['canceled', 'incomplete_expired'].includes(account.subscription_status ?? '') } -interface WorkspaceOption { workspace: WorkspaceItem, eligible: boolean, reason: string | null, attach: boolean, tooSmall?: boolean } +interface WorkspaceOption { workspace: WorkspaceItem, eligible: boolean, reason: string | null, attach: boolean, billingIssue?: 'too_small' | 'past_due' | 'ending' } /** The sold plan behind a workspace's running, paid subscription (Enterprise sits above both). */ function paidPlanOf(workspace: WorkspaceItem): 'starter' | 'pro' | null { const account = workspace.payment_account - if (!account || !['active', 'past_due'].includes(account.subscription_status ?? '')) return null + if (account?.subscription_status !== 'active') return null return account.plan === 'pro' || account.plan === 'enterprise' ? 'pro' : account.plan === 'starter' ? 'starter' : null } @@ -90,20 +90,24 @@ const options = computed(() => workspaces.value.map((workspac if (role !== 'owner' && role !== 'admin') return { workspace, eligible: false, reason: t('migrate_claim.ineligible_role'), attach: false } if (grant.value?.workspaceId && grant.value.workspaceId !== workspace.id) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_bound'), attach: false } if (hasRunningSubscription(workspace)) { - const paid = paidPlanOf(workspace) + const account = workspace.payment_account const needed = grant.value?.plan + if (account?.subscription_status === 'past_due') return { workspace, eligible: false, reason: t('migrate_claim.ineligible_past_due'), attach: false, billingIssue: 'past_due' } + if (account?.subscription_status === 'active' && account.cancel_at_period_end) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_ending'), attach: false, billingIssue: 'ending' } + const paid = paidPlanOf(workspace) if (paid && needed && planCovers(paid, needed)) return { workspace, eligible: true, reason: t('migrate_claim.attach_covers'), attach: true } - if (paid && needed) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_plan_below', { plan: PLAN_PRICING[needed].name }), attach: false, tooSmall: true } + if (paid && needed) return { workspace, eligible: false, reason: t('migrate_claim.ineligible_plan_below', { plan: PLAN_PRICING[needed].name }), attach: false, billingIssue: 'too_small' } return { workspace, eligible: false, reason: t('migrate_claim.ineligible_subscribed'), attach: false } } return { workspace, eligible: true, reason: null, attach: false } })) const selectedOption = computed(() => options.value.find(o => o.workspace.id === selectedWorkspaceId.value) ?? null) -/** Every workspace is taken or too small: the way on is upgrading one, not waiting. */ -const upgradeTarget = computed(() => { +/** Every workspace is taken or cannot take the site: the way on is fixing a plan in billing, not waiting. */ +const billingTarget = computed(() => { if (options.value.some(o => o.eligible)) return null - return options.value.find(o => o.tooSmall)?.workspace ?? null + const option = options.value.find(o => o.billingIssue) + return option?.billingIssue ? { workspace: option.workspace, issue: option.billingIssue } : null }) const trialEndText = computed(() => { @@ -272,10 +276,10 @@ async function startTrial() { -

- {{ t('migrate_claim.upgrade_hint', { plan: PLAN_PRICING[grant.plan].name }) }} - - {{ t('migrate_claim.upgrade_link') }} +

+ {{ t(`migrate_claim.billing_hint_${billingTarget.issue}`, { plan: PLAN_PRICING[grant.plan].name }) }} + + {{ t('migrate_claim.billing_link') }}

diff --git a/server/api/migrate/grants/[grantId]/attach.post.ts b/server/api/migrate/grants/[grantId]/attach.post.ts index 824ca0a8..c9120257 100644 --- a/server/api/migrate/grants/[grantId]/attach.post.ts +++ b/server/api/migrate/grants/[grantId]/attach.post.ts @@ -5,9 +5,11 @@ * delivered site joins a workspace whose running plan covers the grant's * plan, and no second subscription (and no trial) starts. The grant is tied * to the workspace and marked used with no subscription of its own, so the - * claim screen goes straight to the site. A plan below the grant's, or one - * still in its trial or ending, is refused with a reason the screen shows; - * the visitor then upgrades the plan, or picks another workspace. + * claim screen goes straight to the site. Only a paid, active plan that is not + * ending qualifies: a trial, a plan set to end at the period's close, or an + * overdue one is refused with a reason the screen shows (a grant is a one-time + * credit, and a plan that is about to lapse would take the site's Studio with + * it). The visitor then fixes the plan, or picks another workspace. */ import { migrateClaimPublicKey } from '../../../../utils/migrate-grant' import { resolveWorkspaceBilling } from '../../../../utils/workspace-billing' @@ -46,7 +48,9 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 409, message: errorMessage('migrate.grant_bound_elsewhere') }) const billing = await resolveWorkspaceBilling(db, { ...workspace, id: workspaceId } as Parameters[1]) - if (billing.state !== 'subscribed' && billing.state !== 'past_due') + if (billing.state === 'past_due') throw createError({ statusCode: 409, message: errorMessage('migrate.attach_past_due') }) + const account = await db.getActivePaymentAccount(workspaceId) + if (billing.state !== 'subscribed' || account?.cancel_at_period_end === true) throw createError({ statusCode: 409, message: errorMessage('migrate.attach_no_plan') }) const current = billing.effectivePlan === 'enterprise' ? 'pro' : billing.effectivePlan if ((current !== 'starter' && current !== 'pro') || !planCovers(current as MigrateStudioPlan, grant.plan as MigrateStudioPlan)) diff --git a/tests/unit/migrate-grant-attach.test.ts b/tests/unit/migrate-grant-attach.test.ts index 756bd09d..275d5322 100644 --- a/tests/unit/migrate-grant-attach.test.ts +++ b/tests/unit/migrate-grant-attach.test.ts @@ -61,6 +61,20 @@ describe('POST /api/migrate/grants/:grantId/attach', () => { expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() }) + it('refuses a plan that is ending (active, set to cancel at the period\'s end)', async () => { + db.getActivePaymentAccount.mockResolvedValue(account({ cancel_at_period_end: true })) + await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: 'migrate.attach_no_plan' }) + expect(db.bindMigrateGrantWorkspace).not.toHaveBeenCalled() + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + }) + + it('refuses a past_due plan: fix billing first', async () => { + db.getActivePaymentAccount.mockResolvedValue(account({ subscription_status: 'past_due', grace_period_ends_at: '2099-01-01T00:00:00Z' })) + await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: 'migrate.attach_past_due' }) + expect(db.bindMigrateGrantWorkspace).not.toHaveBeenCalled() + expect(db.markMigrateGrantRedeemed).not.toHaveBeenCalled() + }) + it('refuses a workspace with no running paid plan (none, or still in trial)', async () => { db.getActivePaymentAccount.mockResolvedValue(null) await expect(attach()).rejects.toMatchObject({ statusCode: 409, message: 'migrate.attach_no_plan' })