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
3 changes: 3 additions & 0 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 11 additions & 1 deletion .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
63 changes: 55 additions & 8 deletions app/pages/migrate/claim.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@
* 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
* to the migration's media with `?focus=media`, Migrate's "move the media to
* Studio"), the workspace until then.
*/
import { PLAN_PRICING } from '~~/shared/utils/license'
import { planCovers } from '~~/shared/utils/migrate-bundle'

definePageMeta({
layout: false,
Expand Down Expand Up @@ -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<WorkspaceOption[]>(() => 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)
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -189,7 +226,10 @@ async function startTrial() {

<template v-else>
<!-- What is bought, stated before the provider's checkout. -->
<dl class="mt-6 space-y-2 rounded-lg border border-border px-4 py-3 text-sm dark:border-secondary-800">
<p v-if="selectedOption?.attach" class="mt-6 rounded-lg border border-border px-4 py-3 text-sm text-body dark:border-secondary-800 dark:text-secondary-300" data-testid="claim-attach-note">
{{ t('migrate_claim.attach_note') }}
</p>
<dl v-else class="mt-6 space-y-2 rounded-lg border border-border px-4 py-3 text-sm dark:border-secondary-800">
<div class="flex justify-between gap-4">
<dt class="text-label">
{{ t('migrate_claim.due_today') }}
Expand All @@ -207,7 +247,7 @@ async function startTrial() {
</dd>
</div>
</dl>
<p class="mt-2 text-xs text-muted">
<p v-if="!selectedOption?.attach" class="mt-2 text-xs text-muted">
{{ t('migrate_claim.cancel_note') }}
</p>

Expand Down Expand Up @@ -236,6 +276,13 @@ async function startTrial() {
</div>
</div>

<p v-if="billingTarget" class="mt-3 text-sm text-body dark:text-secondary-300" data-testid="claim-upgrade">
{{ t(`migrate_claim.billing_hint_${billingTarget.issue}`, { plan: PLAN_PRICING[grant.plan].name }) }}
<NuxtLink :to="`/w/${billingTarget.workspace.slug}/settings?tab=billing`" class="font-medium text-primary-700 underline dark:text-primary-300">
{{ t('migrate_claim.billing_link') }}
</NuxtLink>
</p>

<p v-if="submitError" class="mt-4 text-sm text-danger-600 dark:text-danger-400" role="alert">
{{ submitError }}
</p>
Expand All @@ -246,7 +293,7 @@ async function startTrial() {
:disabled="!selectedWorkspaceId || submitting"
@click="startTrial"
>
{{ t('migrate_claim.start', { days: grant.trialDays }) }}
{{ selectedOption?.attach ? t('migrate_claim.attach_button') : t('migrate_claim.start', { days: grant.trialDays }) }}
</AtomsBaseButton>
</template>
</div>
Expand Down
63 changes: 63 additions & 0 deletions server/api/migrate/grants/[grantId]/attach.post.ts
Original file line number Diff line number Diff line change
@@ -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<typeof resolveWorkspaceBilling>[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 }
})
Loading
Loading