fix(cloud): EP-11 the plan blocker says 'ended' only for a plan that existed

The creating sheet's (and waking card's) plan block always said 'Your team's plan has ended.', even
to a brand-new team that never paid. The body now follows billing's reason: subscription_inactive
-> ended; trial_used_up -> trial used up; no_subscription / trial_unavailable -> no plan yet;
unknown -> needs a plan. Fixtures for each; tests.
This commit is contained in:
Neil
2026-11-13 05:28:00 -08:00
parent 55505e7d09
commit bee6f35aa9
5 changed files with 87 additions and 11 deletions
@@ -42,7 +42,7 @@ export function cloudWakeBillingCopy(
// Running limits and anything unknown: the shared workspace copy (it says what to do).
return { body: describeError('entitlement_denied', reason, t), fix: null }
}
const copy = blockerCopy(kind, t)
const copy = blockerCopy(kind, t, reason)
// The sheet's payment line is about new sandboxes; this one already exists.
const body =
kind === 'payment'
@@ -38,7 +38,17 @@ afterEach(() => {
function renderBlocker(
kind: BillingBlockKind,
{ canManageBilling = true, capCents = 2500, offers = true } = {}
{
canManageBilling = true,
capCents = 2500,
offers = true,
reason
}: {
canManageBilling?: boolean
capCents?: number
offers?: boolean
reason?: string | null
} = {}
): {
onContinue: ReturnType<typeof vi.fn>
onBackToDraft: ReturnType<typeof vi.fn>
@@ -53,7 +63,8 @@ function renderBlocker(
entitlement={{
...sampleTeam,
offers: offers ? sampleTeam.offers : null,
overage: { ...sampleTeam.overage, spendCapCents: capCents }
overage: { ...sampleTeam.overage, spendCapCents: capCents },
...(reason !== undefined ? { reason: reason as never } : {})
}}
onContinue={onContinue}
onBackToDraft={onBackToDraft}
@@ -147,6 +158,20 @@ describe('billing blocker on the creating sheet (JA-21)', () => {
expect(openPlansPage).toHaveBeenCalledWith('sandbox')
})
it.each([
['subscription_inactive', 'Your team’s plan has ended.'],
['trial_used_up', 'Your team’s trial is used up.'],
['no_subscription', 'Your team doesn’t have a plan yet.'],
['trial_unavailable', 'Your team doesn’t have a plan yet.'],
[null, 'Your team needs a plan to use Orca Cloud.']
] as const)('no plan (%s): only a plan that existed has "ended" (EP-11)', (reason, body) => {
renderBlocker('plan', { reason })
expect(screen.getByText(body)).toBeTruthy()
if (reason !== 'subscription_inactive') {
expect(screen.queryByText('Your team’s plan has ended.')).toBeNull()
}
})
it.each([
['hoursUsed', 'Ask an owner or admin to add hours.'],
['spendCap', 'Ask an owner or admin to raise the spend cap.'],
@@ -22,20 +22,42 @@ export type BillingBlockerPhase =
/** A fix in the browser (portal): re-check on return, or with Check again. */
| { kind: 'browser'; checking: boolean; stillBlocked: boolean }
/** Why there is no plan, in billing's words: only a plan that existed has "ended". */
function planBlockBody(reason: string | null | undefined, t: Translate): string {
switch (reason) {
case 'subscription_inactive':
return t('worktreeCreation.billingBlock.plan.body2', 'Your team’s plan has ended.')
case 'trial_used_up':
return t(
'worktreeCreation.billingBlock.plan.bodyTrialUsedUp',
'Your team’s trial is used up.'
)
case 'no_subscription':
case 'trial_unavailable':
return t(
'worktreeCreation.billingBlock.plan.bodyNoPlan',
'Your team doesn’t have a plan yet.'
)
default:
return t(
'worktreeCreation.billingBlock.plan.bodyNeedsPlan',
'Your team needs a plan to use Orca Cloud.'
)
}
}
/** The blocker's words per refusal; also the waking card's, so a refused wake says the same. */
export function blockerCopy(
kind: BillingBlockKind,
t: Translate
t: Translate,
/** Billing's refusal reason: says whether a plan ended, a trial ran out, or there never was one (EP-11). */
reason?: string | null
): { title: string; body: string; ask: string } {
switch (kind) {
case 'plan':
return {
title: t('worktreeCreation.billingBlock.plan.title', 'Choose a plan'),
body: t(
// Only teams that had Orca Cloud reach the sheet; a never-planned team gets setup.
'worktreeCreation.billingBlock.plan.body2',
'Your team’s plan has ended.'
),
body: planBlockBody(reason, t),
ask: t('worktreeCreation.billingBlock.plan.ask', 'Ask an owner or admin to choose a plan.')
}
case 'hoursUsed':
@@ -73,6 +95,8 @@ export function blockerCopy(
export type VmBuildBillingBlockerViewProps = {
kind: BillingBlockKind
/** Billing's refusal reason (plan blocks word "ended" vs "no plan yet" from it). */
reason?: string | null
canManageBilling: boolean
phase: BillingBlockerPhase
/** Spend cap reached: the field's starting value, in whole dollars. */
@@ -93,6 +117,7 @@ export type VmBuildBillingBlockerViewProps = {
*/
export function VmBuildBillingBlockerView({
kind,
reason,
canManageBilling,
phase,
suggestedCapDollars,
@@ -106,7 +131,7 @@ export function VmBuildBillingBlockerView({
}: VmBuildBillingBlockerViewProps): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
const copy = blockerCopy(kind, t)
const copy = blockerCopy(kind, t, reason)
const capId = React.useId()
const [cap, setCap] = React.useState(String(suggestedCapDollars))
const headingRef = React.useRef<HTMLHeadingElement>(null)
@@ -340,6 +365,7 @@ export function VmBuildBillingBlocker({
return (
<VmBuildBillingBlockerView
kind={kind}
reason={entitlement?.reason ?? null}
canManageBilling={canManageBilling}
phase={phase}
suggestedCapDollars={suggestedCapDollars}
@@ -41,6 +41,28 @@ export const cloudPlanRequired = cloudFailure(
''
)
// EP-11: the plan blocker's body follows billing's reason; only a plan that existed has "ended".
export const cloudPlanRequiredNeverPaid = cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours. Open Plan & billing to choose one.',
'',
{ plan: null, status: 'none', reason: 'no_subscription' }
)
export const cloudPlanRequiredEnded = cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours. Open Plan & billing to choose one.',
'',
{ status: 'canceled', reason: 'subscription_inactive' }
)
export const cloudPlanRequiredTrialUsedUp = cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours. Open Plan & billing to choose one.',
'',
{ plan: null, status: 'trial', reason: 'trial_used_up' }
)
export const cloudConcurrencyLimit = cloudFailure(
'cloudConcurrencyLimit',
'You already have as many Orca Cloud workspaces running as your plan allows.',
+4 -1
View File
@@ -29682,7 +29682,10 @@
"plan": {
"title": "Choose a plan",
"ask": "Ask an owner or admin to choose a plan.",
"body2": "Your team’s plan has ended."
"body2": "Your team’s plan has ended.",
"bodyTrialUsedUp": "Your team’s trial is used up.",
"bodyNoPlan": "Your team doesn’t have a plan yet.",
"bodyNeedsPlan": "Your team needs a plan to use Orca Cloud."
},
"hours": {
"title": "Out of sandbox hours",