diff --git a/.contentrain/content/system/error-messages/en.json b/.contentrain/content/system/error-messages/en.json index 9af26796..26b01c8a 100644 --- a/.contentrain/content/system/error-messages/en.json +++ b/.contentrain/content/system/error-messages/en.json @@ -244,6 +244,9 @@ "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 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.", "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..00c78ad7 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -807,7 +807,14 @@ "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.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.", @@ -819,6 +826,9 @@ "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", "migrate_claim.kicker": "Included with your migration", diff --git a/app/pages/migrate/claim.vue b/app/pages/migrate/claim.vue index bcf75d12..02a83b0c 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,40 @@ 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, 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?.subscription_status !== 'active') 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 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, 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 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 + const option = options.value.find(o => o.billingIssue) + return option?.billingIssue ? { workspace: option.workspace, issue: option.billingIssue } : null +}) + const trialEndText = computed(() => { if (!grant.value) return '' const end = new Date(Date.now() + grant.value.trialDays * 24 * 60 * 60 * 1000) @@ -120,6 +148,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 +226,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..c9120257 --- /dev/null +++ b/server/api/migrate/grants/[grantId]/attach.post.ts @@ -0,0 +1,63 @@ +/** + * 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. 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' +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 === '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)) + 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..275d5322 --- /dev/null +++ b/tests/unit/migrate-grant-attach.test.ts @@ -0,0 +1,103 @@ +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 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' }) + 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 }) + }) +})