fix(cloud): PAIRED-BILLING design-squad: hours used turns overage on here and wakes again; Compare plans

Hours used: an owner's fix is the create sheet's [Turn on with a $X cap] (billing's default cap; plain
Turn on overage without one), applied in place, then the wake runs again. Plan ended: Compare plans,
the one label for that action (also on the Restart/Sleep failure toast). Drops the private
orcaCloudWorkspace.error.seePlans key it no longer uses.
This commit is contained in:
Neil
2026-11-12 15:54:54 -08:00
parent 062d85cec6
commit a2d49d00a2
6 changed files with 134 additions and 56 deletions
@@ -0,0 +1,117 @@
// The one fix on a refused wake's card, for an owner or admin, with the create sheet's labels and
// behaviour: Turn on overage applies here and wakes again; the others open where the fix is made.
import React from 'react'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useIntlLocale, useTranslate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import {
getLastBillingStatus,
refreshBillingSummary
} from '@/components/billing/billing-summary-store'
import { overageFailureCopy } from '@/components/billing/plan-billing-copy'
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
import { formatCents } from '@/components/billing/plans/plans-pricing'
import { ORCA_BILLING_OVERAGE_SECTION_ID } from '@/components/new-workspace/orca-cloud-run-target-access'
import type { OrcaBillingOverageResult } from '../../../../shared/orca-billing'
import type { CloudWakeBillingFix } from './cloud-wake-billing'
function openBillingSettings(fix: 'raiseSpendCap' | 'updatePayment'): void {
const store = useAppStore.getState()
store.openSettingsTarget({
pane: 'cloud-billing',
repoId: null,
// The spend cap lives on the overage card; the payment notice tops the section already.
...(fix === 'raiseSpendCap' ? { sectionId: ORCA_BILLING_OVERAGE_SECTION_ID } : {})
})
store.openSettingsPage()
}
export function CloudWakeBillingFixButton({
fix,
onRetry
}: {
fix: CloudWakeBillingFix
/** Wakes again (after overage is on). */
onRetry: () => void
}): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
const [saving, setSaving] = React.useState(false)
const status = getLastBillingStatus()
const entitlement = status?.status === 'ok' ? status.entitlement : null
// Plan numbers come from billing, never guessed (ENTITLEMENTS-C).
const defaultCapCents = entitlement?.offers?.team.defaultSpendCapCents ?? null
const maxCapCents = entitlement?.offers?.team.maxSpendCapCents ?? null
const turnOnOverage = async (): Promise<void> => {
setSaving(true)
const outcome = await window.api.orcaProfiles
.billingSetOverage(
defaultCapCents === null
? { enabled: true }
: { enabled: true, spendCapCents: defaultCapCents }
)
.catch((error: unknown): OrcaBillingOverageResult => ({
status: 'failed',
error: String(error)
}))
setSaving(false)
if (outcome.status === 'ok') {
void refreshBillingSummary(true)
onRetry()
return
}
toast.error(
overageFailureCopy(outcome, t, maxCapCents, locale) ??
t(
'worktreeCreation.billingBlock.saveFailed',
'Couldn’t save that. Try again, or change it in Plan & billing.'
)
)
}
switch (fix) {
case 'comparePlans':
return (
<Button size="sm" onClick={() => openPlansPage('sandbox')}>
{t('auto.components.billing.comparePlans', 'Compare plans')}
</Button>
)
case 'turnOnOverage':
return (
<Button
size="sm"
aria-busy={saving || undefined}
aria-disabled={saving || undefined}
onClick={saving ? undefined : () => void turnOnOverage()}
>
{saving ? <Loader2 className="animate-spin" /> : null}
{defaultCapCents === null
? t('auto.components.billing.overage.turnOnNoCap', 'Turn on overage')
: t('auto.components.billing.overage.turnOn', 'Turn on with a {{cap}} cap', {
cap: formatCents(defaultCapCents, locale)
})}
</Button>
)
case 'raiseSpendCap':
return (
<Button size="sm" onClick={() => openBillingSettings('raiseSpendCap')}>
{t(
'auto.components.worktree.creation.VmBuildFailureCard.raiseSpendCap',
'Raise spend cap'
)}
</Button>
)
case 'updatePayment':
return (
<Button size="sm" onClick={() => openBillingSettings('updatePayment')}>
{t(
'auto.components.worktree.creation.VmBuildFailureCard.updatePayment',
'Update payment method'
)}
</Button>
)
}
}
@@ -9,11 +9,8 @@ import { cn } from '@/lib/class-names'
import { wakeElapsedSeconds } from '@/lib/orca-cloud/orca-cloud-workspace-state'
import { TERMINAL_CONTENT_INSET_PX, useTerminalContentInset } from './use-terminal-content-inset'
import { useBillingSummary } from '@/components/billing/billing-summary-store'
import {
cloudWakeBillingCopy,
cloudWakeBillingFixLabel,
runCloudWakeBillingFix
} from './cloud-wake-billing'
import { cloudWakeBillingCopy } from './cloud-wake-billing'
import { CloudWakeBillingFixButton } from './CloudWakeBillingFixButton'
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
/** After this long the pill says it's slower than usual; after STUCK_S it offers recovery inline. */
@@ -263,11 +260,7 @@ export function CloudWakingOverlay({
>
{t('orcaCloudWorkspace.overlay.tryAgain', 'Try again')}
</Button>
{billingFix ? (
<Button size="sm" onClick={() => runCloudWakeBillingFix(billingFix)}>
{cloudWakeBillingFixLabel(billingFix, t)}
</Button>
) : null}
{billingFix ? <CloudWakeBillingFixButton fix={billingFix} onRetry={onRetry} /> : null}
</div>
</div>
) : (
@@ -1,8 +1,6 @@
// A wake the plan refused says why in the create sheet's words, offers the fix only to an owner or
// admin (a member reads who to ask), and never shows the control plane's code.
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
import { describe, expect, it } from 'vitest'
import { translate } from '@/i18n/i18n'
import { cloudWakeBillingCopy } from './cloud-wake-billing'
@@ -32,8 +30,9 @@ describe('cloudWakeBillingCopy', () => {
it('an owner or admin gets the fix that applies', () => {
const fix = (reason: string) => cloudWakeBillingCopy(failed(reason), true, translate)?.fix
expect(fix('subscription_inactive')).toBe('seePlans')
expect(fix('included_hours_exhausted')).toBe('seePlans')
expect(fix('subscription_inactive')).toBe('comparePlans')
// As on the create sheet: one click turns overage on here, then it wakes again.
expect(fix('included_hours_exhausted')).toBe('turnOnOverage')
expect(fix('overage_cap_reached')).toBe('raiseSpendCap')
expect(fix('payment_past_due')).toBe('updatePayment')
})
@@ -2,26 +2,28 @@
// create sheet's billing blocker, and offers the one fix that applies to someone who can make it
// (owner or admin). A member reads who to ask. Never the control plane's code.
import type { Translate } from '@/i18n/use-translate'
import { useAppStore } from '@/store'
import { describeError } from '@/components/cloud-workspaces/cloud-workspace-errors'
import {
blockerCopy,
type BillingBlockKind
} from '@/components/worktree-creation/VmBuildBillingBlocker'
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
import { ORCA_BILLING_OVERAGE_SECTION_ID } from '@/components/new-workspace/orca-cloud-run-target-access'
import { classifyEntitlementDenial } from '../../../../shared/orca-billing'
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
export type CloudWakeBillingFix = 'seePlans' | 'raiseSpendCap' | 'updatePayment'
export type CloudWakeBillingFix =
| 'comparePlans'
| 'turnOnOverage'
| 'raiseSpendCap'
| 'updatePayment'
export type CloudWakeBillingCopy = { body: string; fix: CloudWakeBillingFix | null }
const BLOCK_KIND: Partial<Record<ReturnType<typeof classifyEntitlementDenial>, BillingBlockKind>> =
{ plan: 'plan', hours: 'hoursUsed', spendCap: 'spendCap', payment: 'payment' }
const FIX: Record<BillingBlockKind, CloudWakeBillingFix> = {
plan: 'seePlans',
hoursUsed: 'seePlans',
plan: 'comparePlans',
// One click fixes it here, as on the create sheet: overage on at billing's default cap, then wake.
hoursUsed: 'turnOnOverage',
spendCap: 'raiseSpendCap',
payment: 'updatePayment'
}
@@ -48,35 +50,3 @@ export function cloudWakeBillingCopy(
: copy.body
return canManageBilling ? { body, fix: FIX[kind] } : { body: `${body} ${copy.ask}`, fix: null }
}
export function cloudWakeBillingFixLabel(fix: CloudWakeBillingFix, t: Translate): string {
switch (fix) {
case 'seePlans':
return t('orcaCloudWorkspace.error.seePlans', 'See plans')
case 'raiseSpendCap':
return t(
'auto.components.worktree.creation.VmBuildFailureCard.raiseSpendCap',
'Raise spend cap'
)
case 'updatePayment':
return t(
'auto.components.worktree.creation.VmBuildFailureCard.updatePayment',
'Update payment method'
)
}
}
export function runCloudWakeBillingFix(fix: CloudWakeBillingFix): void {
if (fix === 'seePlans') {
openPlansPage('sandbox')
return
}
const store = useAppStore.getState()
store.openSettingsTarget({
pane: 'cloud-billing',
repoId: null,
// The spend cap lives on the overage card; the payment notice tops the section already.
...(fix === 'raiseSpendCap' ? { sectionId: ORCA_BILLING_OVERAGE_SECTION_ID } : {})
})
store.openSettingsPage()
}
@@ -32,7 +32,7 @@ function failureToast(title: string, result: CloudWorkspaceResult<null>): void {
}
if (result.code === 'entitlement_denied') {
// The shared classifier: the actual reason (plan, hours, spend cap, payment, limits), and
// "See plans" only where buying fixes it and only for people who can (owner or admin).
// "Compare plans" only where buying fixes it and only for people who can (owner or admin).
const denial = classifyEntitlementDenial(result.reason)
const canFixWithPlans =
(denial === 'plan' || denial === 'hours') && getBillingSummary().canManageBilling
@@ -41,7 +41,7 @@ function failureToast(title: string, result: CloudWorkspaceResult<null>): void {
...(canFixWithPlans
? {
action: {
label: translate('orcaCloudWorkspace.error.seePlans', 'See plans'),
label: translate('auto.components.billing.comparePlans', 'Compare plans'),
onClick: () => openPlansPage('sandbox')
}
}
-1
View File
@@ -27760,7 +27760,6 @@
"ago": "{{ago}} ago",
"wake": "Wake",
"error": {
"seePlans": "See plans",
"unavailable": "Orca Cloud isn’t reachable right now. Try again in a moment.",
"generic": "Something went wrong. Try again."
},