From a2d49d00a216dd11eb457154b939c5aada858fb4 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 15:54:54 -0800 Subject: [PATCH] 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. --- .../orca-cloud/CloudWakeBillingFixButton.tsx | 117 ++++++++++++++++++ .../orca-cloud/CloudWakingOverlay.tsx | 13 +- .../orca-cloud/cloud-wake-billing.test.ts | 9 +- .../orca-cloud/cloud-wake-billing.ts | 46 ++----- .../orca-cloud/cloud-workspace-actions.ts | 4 +- src/renderer/src/i18n/locales/en.json | 1 - 6 files changed, 134 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/orca-cloud/CloudWakeBillingFixButton.tsx diff --git a/src/renderer/src/components/orca-cloud/CloudWakeBillingFixButton.tsx b/src/renderer/src/components/orca-cloud/CloudWakeBillingFixButton.tsx new file mode 100644 index 00000000000..3f2033a7f57 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudWakeBillingFixButton.tsx @@ -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 => { + 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 ( + + ) + case 'turnOnOverage': + return ( + + ) + case 'raiseSpendCap': + return ( + + ) + case 'updatePayment': + return ( + + ) + } +} diff --git a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx index b65f05a3703..63350209db7 100644 --- a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx @@ -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')} - {billingFix ? ( - - ) : null} + {billingFix ? : null} ) : ( diff --git a/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts b/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts index a39cadb6d01..5c1a50676e6 100644 --- a/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts +++ b/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts @@ -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') }) diff --git a/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts b/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts index 70f793b8fd4..a4c27c84761 100644 --- a/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts +++ b/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts @@ -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, BillingBlockKind>> = { plan: 'plan', hours: 'hoursUsed', spendCap: 'spendCap', payment: 'payment' } const FIX: Record = { - 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() -} diff --git a/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts b/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts index 8c7ebd53015..7b527254c11 100644 --- a/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts +++ b/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts @@ -32,7 +32,7 @@ function failureToast(title: string, result: CloudWorkspaceResult): 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): void { ...(canFixWithPlans ? { action: { - label: translate('orcaCloudWorkspace.error.seePlans', 'See plans'), + label: translate('auto.components.billing.comparePlans', 'Compare plans'), onClick: () => openPlansPage('sandbox') } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 27158210b42..f7c49d19938 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." },