From 062d85cec622ff5c873d91eae85ed54e63ecab74 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 12 Nov 2026 15:45:37 -0800 Subject: [PATCH] fix(cloud): PAIRED-BILLING a wake the plan refused says why, never the code The waking card showed the control plane's code on a billing refusal (paired: "entitlement_denied"; owned: "Orca Cloud refused the request (entitlement_denied).") and offered Restart, which fails the same way. Failed wake/restart/unarchive events now carry the plan's refusal (billingDenial), from main's recipe resume, restart and unarchive and from the paired wake. The card uses the create sheet's billing words, offers the one fix to an owner or admin (See plans, Raise spend cap, Update payment method) and tells a member who to ask; running limits keep the shared workspace copy. --- .../hosted-environment-provider.ts | 5 ++ .../hosted-sandbox/hosted-sandbox-install.ts | 9 +- .../hosted-sandbox/hosted-sandbox-recipe.ts | 5 +- .../hosted-wake-billing-denial.test.ts | 18 ++++ .../hosted-workspace-service.ts | 7 +- .../orca-cloud/CloudWakingOverlay.tsx | 28 ++++++- .../OrcaCloudWorkspaceWake.fixture.tsx | 41 ++++++++++ .../orca-cloud/cloud-wake-billing.test.ts | 65 +++++++++++++++ .../orca-cloud/cloud-wake-billing.ts | 82 +++++++++++++++++++ .../VmBuildBillingBlocker.tsx | 3 +- src/renderer/src/i18n/locales/en.json | 3 +- .../lib/orca-cloud/paired-cloud-wake.test.ts | 18 ++++ .../src/lib/orca-cloud/paired-cloud-wake.ts | 13 ++- .../hosted-sandbox/cloud-workspace-api.ts | 2 + 14 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts create mode 100644 src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts create mode 100644 src/renderer/src/components/orca-cloud/cloud-wake-billing.ts diff --git a/src/main/hosted-sandbox/hosted-environment-provider.ts b/src/main/hosted-sandbox/hosted-environment-provider.ts index 948856e89f5..62c498136fe 100644 --- a/src/main/hosted-sandbox/hosted-environment-provider.ts +++ b/src/main/hosted-sandbox/hosted-environment-provider.ts @@ -72,3 +72,8 @@ export class HostedEntitlementDeniedError extends EnvironmentProviderError { export function isHostedEntitlementDenied(error: unknown): error is HostedEntitlementDeniedError { return error instanceof HostedEntitlementDeniedError } + +/** For a failed wake/restart/unarchive event: the plan's refusal, so the card can say why. */ +export function wakeBillingDenial(error: unknown): { billingDenial?: { reason: string | null } } { + return isHostedEntitlementDenied(error) ? { billingDenial: { reason: error.reason ?? null } } : {} +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts index 2904c942919..1094d252345 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-install.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts @@ -245,14 +245,11 @@ export function installHostedSandbox( usageRefresh.noteStarted() }, onStopped: (environmentId) => registry.stopEnvironment(environmentId), - onWake: ({ workspaceId, phase, startedAt, message }) => + onWake: (event) => workspaces.emitWakeProgress({ - workspaceId, - phase, - startedAt, - message, + ...event, // Later phases inherit the source recorded at `waking`. - source: phase === 'waking' ? workspaces.takeWakeSource(workspaceId) : 'open' + source: event.phase === 'waking' ? workspaces.takeWakeSource(event.workspaceId) : 'open' }) }) installed = { registry } diff --git a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts index 578969efb9e..48d3b3de8b5 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts @@ -15,6 +15,7 @@ import { isEnvironmentProviderError } from '../environment-providers/environment import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter' import { isHostedEntitlementDenied, + wakeBillingDenial, type HostedEnvironmentProviderDriver, type HostedEnvironmentReady } from './hosted-environment-provider' @@ -153,6 +154,7 @@ export function createHostedSandboxStepProvider( phase: 'waking' | 'connecting' | 'ready' | 'failed' startedAt: number message?: string + billingDenial?: { reason: string | null } }) => void } = {} ): RecipeStepProvider { @@ -220,7 +222,8 @@ export function createHostedSandboxStepProvider( workspaceId, phase: 'failed', startedAt, - message: error instanceof Error ? error.message : String(error) + message: error instanceof Error ? error.message : String(error), + ...wakeBillingDenial(error) }) } throw error diff --git a/src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts b/src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts new file mode 100644 index 00000000000..e4d8a7748f7 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-wake-billing-denial.test.ts @@ -0,0 +1,18 @@ +// A failed wake/restart/unarchive carries the plan's refusal, so the window's card can say why. +import { describe, expect, it } from 'vitest' +import { HostedEntitlementDeniedError, wakeBillingDenial } from './hosted-environment-provider' + +describe('wakeBillingDenial', () => { + it('carries the billing reason of a plan refusal', () => { + const error = new HostedEntitlementDeniedError('refused', 'subscription_inactive') + expect(wakeBillingDenial(error)).toEqual({ billingDenial: { reason: 'subscription_inactive' } }) + expect(wakeBillingDenial(new HostedEntitlementDeniedError('refused', null))).toEqual({ + billingDenial: { reason: null } + }) + }) + + it('adds nothing for any other failure', () => { + expect(wakeBillingDenial(new Error('boom'))).toEqual({}) + expect(wakeBillingDenial('x')).toEqual({}) + }) +}) diff --git a/src/main/hosted-sandbox/hosted-workspace-service.ts b/src/main/hosted-sandbox/hosted-workspace-service.ts index e8c66bf5964..f4bbf2e487d 100644 --- a/src/main/hosted-sandbox/hosted-workspace-service.ts +++ b/src/main/hosted-sandbox/hosted-workspace-service.ts @@ -23,6 +23,7 @@ import { invalidateRuntimeEnvironmentTransport } from '../ipc/runtime-environmen import { autoResumeAgentsAfterRollover, listRestartOfferIds } from './hosted-agent-auto-resume' import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' import { toReady } from './hosted-sandbox-driver' +import { wakeBillingDenial } from './hosted-environment-provider' import { noteWakeTimeline } from './cloud-wake-timeline' import { createHostedRuntimeEnvironmentCall } from './hosted-runtime-environment-call' import { getCloudSyncService } from '../cloud-sync/cloud-sync-ipc' @@ -255,7 +256,8 @@ export function installHostedWorkspaceService(deps: { phase: 'failed', source: 'restart', startedAt, - message: error instanceof Error ? error.message : String(error) + message: error instanceof Error ? error.message : String(error), + ...wakeBillingDenial(error) }) deps.syncSessions() return failure(error) @@ -313,7 +315,8 @@ export function installHostedWorkspaceService(deps: { phase: 'failed', source: 'unarchive', startedAt, - message: error instanceof Error ? error.message : String(error) + message: error instanceof Error ? error.message : String(error), + ...wakeBillingDenial(error) }) return failure(error) } diff --git a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx index 8507aab0019..b65f05a3703 100644 --- a/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWakingOverlay.tsx @@ -8,6 +8,12 @@ import { useTranslate } from '@/i18n/use-translate' 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 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. */ @@ -101,6 +107,10 @@ export function CloudWakingOverlay({ const t = useTranslate() const failed = wake.phase === 'failed' const unknownServer = failed && isUnknownServerWakeFailure(wake.message) + const { canManageBilling } = useBillingSummary() + // The plan refused it: say why, offer the fix to someone who can make it, no Restart (it fails too). + const billing = cloudWakeBillingCopy(wake, canManageBilling, t) + const billingFix = billing?.fix ?? null const now = useNow(1_000, !failed && !leaving) const seconds = wakeElapsedSeconds(wake.startedAt, Math.max(now, wake.at)) const slow = !failed && seconds >= WAKE_SLOW_S @@ -215,7 +225,9 @@ export function CloudWakingOverlay({ name })} - {wake.message && !unknownServer ? ( + {billing ? ( +

{billing.body}

+ ) : wake.message && !unknownServer ? (

{wake.message}

) : null}

@@ -238,14 +250,24 @@ export function CloudWakingOverlay({

- {terminalStalled ? null : ( + {terminalStalled || billing ? null : ( )} - + {billingFix ? ( + + ) : null}
) : ( diff --git a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx index 348180dc878..b9ac003dfca 100644 --- a/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx +++ b/src/renderer/src/components/orca-cloud/OrcaCloudWorkspaceWake.fixture.tsx @@ -4,6 +4,8 @@ import React from 'react' import { defineFixture } from '../../component-fixtures/component-fixture' import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api' import { CloudWakingOverlay } from './CloudWakingOverlay' +import { recordBillingStatus } from '../billing/billing-summary-store' +import { sampleTeam } from '../billing/plan-billing-fixtures' const noop = (): void => {} @@ -128,3 +130,42 @@ export const wakeFailedServerError = defineFixture({ ) }) + +function stubBillingRole(canManageBilling: boolean): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture entitlement overrides only the role the card reads. + const result = { status: 'ok', entitlement: { ...sampleTeam, canManageBilling } } as never + window.api.orcaProfiles.billingStatus = () => Promise.resolve(result) + recordBillingStatus(result) +} + +// The plan refused the wake: the card says why in plain words (never "entitlement_denied"), offers +// plans only where buying fixes it and only to an owner or admin, and drops Restart (it fails too). +function wakeRefused(reason: string | null, canManageBilling = true) { + return defineFixture({ + width: 640, + setup: () => stubBillingRole(canManageBilling), + render: () => ( + + + + ) + }) +} + +export const wakeRefusedPlanEnded = wakeRefused('subscription_inactive') +export const wakeRefusedHoursUsed = wakeRefused('included_hours_exhausted') +export const wakeRefusedSpendCap = wakeRefused('overage_cap_reached') +export const wakeRefusedPayment = wakeRefused('payment_past_due') +export const wakeRefusedTeamLimit = wakeRefused('concurrency_limit') +export const wakeRefusedPlanEndedMember = wakeRefused('subscription_inactive', false) 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 new file mode 100644 index 00000000000..a39cadb6d01 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/cloud-wake-billing.test.ts @@ -0,0 +1,65 @@ +// 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 { translate } from '@/i18n/i18n' +import { cloudWakeBillingCopy } from './cloud-wake-billing' +import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api' + +const failed = (reason: string | null, over: Partial = {}) => + ({ + workspaceId: 'ws', + phase: 'failed', + source: 'open', + startedAt: 1, + at: 2, + message: 'entitlement_denied', + billingDenial: { reason }, + ...over + }) satisfies CloudWakeProgressEvent + +describe('cloudWakeBillingCopy', () => { + it('is null for a failure the plan did not cause, and while still waking', () => { + expect( + cloudWakeBillingCopy({ ...failed(null), billingDenial: undefined }, true, translate) + ).toBeNull() + expect( + cloudWakeBillingCopy(failed('subscription_inactive', { phase: 'waking' }), true, translate) + ).toBeNull() + }) + + 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('overage_cap_reached')).toBe('raiseSpendCap') + expect(fix('payment_past_due')).toBe('updatePayment') + }) + + it('a member gets no fix button, and the copy says who to ask', () => { + const copy = cloudWakeBillingCopy(failed('subscription_inactive'), false, translate) + expect(copy?.fix).toBeNull() + expect(copy?.body).toMatch(/Ask an owner or admin/) + // An owner is never told to ask an owner. + expect( + cloudWakeBillingCopy(failed('subscription_inactive'), true, translate)?.body + ).not.toMatch(/Ask an owner/) + }) + + it('running limits and unknown reasons use the shared copy, with no billing fix', () => { + expect(cloudWakeBillingCopy(failed('concurrency_limit'), true, translate)?.fix).toBeNull() + expect(cloudWakeBillingCopy(failed('something_new'), true, translate)?.fix).toBeNull() + }) + + it('never shows the raw code', () => { + for (const reason of [null, 'subscription_inactive', 'concurrency_limit', 'something_new']) { + for (const owner of [true, false]) { + const copy = cloudWakeBillingCopy(failed(reason), owner, translate) + expect(copy?.body).toBeTruthy() + expect(copy?.body).not.toMatch(/entitlement|_/) + } + } + }) +}) diff --git a/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts b/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts new file mode 100644 index 00000000000..70f793b8fd4 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/cloud-wake-billing.ts @@ -0,0 +1,82 @@ +// A wake, restart or unarchive the plan refused: the failure card says why in the same words as the +// 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 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', + spendCap: 'raiseSpendCap', + payment: 'updatePayment' +} + +export function cloudWakeBillingCopy( + wake: CloudWakeProgressEvent, + canManageBilling: boolean, + t: Translate +): CloudWakeBillingCopy | null { + if (wake.phase !== 'failed' || !wake.billingDenial) { + return null + } + const reason = wake.billingDenial.reason ?? undefined + const kind = BLOCK_KIND[classifyEntitlementDenial(reason)] + if (!kind) { + // 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) + // The sheet's payment line is about new sandboxes; this one already exists. + const body = + kind === 'payment' + ? t('orcaCloudWorkspace.overlay.billingPayment', 'Your team’s last payment failed.') + : 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/worktree-creation/VmBuildBillingBlocker.tsx b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx index e77f849fc96..b7eb6b6f8e7 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildBillingBlocker.tsx @@ -22,7 +22,8 @@ export type BillingBlockerPhase = /** A fix in the browser (portal): re-check on return, or with Check again. */ | { kind: 'browser'; checking: boolean; stillBlocked: boolean } -function blockerCopy( +/** 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 ): { title: string; body: string; ask: string } { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 368ad09f13f..27158210b42 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27699,7 +27699,8 @@ "pendingKeys_one": "{{count}} key waiting · it goes to the terminal when it’s up", "pendingKeys_other": "{{count}} keys waiting · they go to the terminal when it’s up", "clearPendingKeys": "Discard waiting keys", - "unarchivingNow": "Unarchiving…" + "unarchivingNow": "Unarchiving…", + "billingPayment": "Your team’s last payment failed." }, "announce": { "restarting": "Restarting {{name}}.", diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts index 1128e78feee..6222ac3bc82 100644 --- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.test.ts @@ -85,6 +85,24 @@ describe('waking a paired workspace', () => { expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('failed') }) + it('a plan refusal marks the failed wake as billing, so the card says why (never the code)', async () => { + wake.mockResolvedValueOnce({ + ok: false, + error: 'entitlement_denied', + reason: 'included_hours_exhausted' + }) + await wakePairedCloudWorkspace(ROW) + expect(getCloudWorkspaceSnapshot().wake[ROW]?.billingDenial).toEqual({ + reason: 'included_hours_exhausted' + }) + }) + + it('any other refusal is not marked as billing', async () => { + wake.mockResolvedValueOnce({ ok: false, error: 'Sandbox hours used up', reason: 'quota' }) + await wakePairedCloudWorkspace(ROW) + expect(getCloudWorkspaceSnapshot().wake[ROW]?.billingDenial).toBeUndefined() + }) + it('a predictive (quiet) wake that is refused leaves no failed card behind', async () => { wake.mockResolvedValueOnce({ ok: false, error: 'Sandbox hours used up', reason: 'quota' }) expect((await wakePairedCloudWorkspace(ROW, { quiet: true })).ok).toBe(false) diff --git a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts index 013e563227f..0e08a985a04 100644 --- a/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts +++ b/src/renderer/src/lib/orca-cloud/paired-cloud-wake.ts @@ -48,7 +48,10 @@ export async function wakePairedCloudWorkspace( } const startedAt = Date.now() const source = options.source ?? 'open' - const report = (phase: 'waking' | 'ready' | 'failed', message?: string): void => { + const report = ( + phase: 'waking' | 'ready' | 'failed', + failure?: { message: string; reason?: string } + ): void => { if (!options.quiet) { noteCloudWakeProgress({ workspaceId, @@ -56,7 +59,11 @@ export async function wakePairedCloudWorkspace( source, startedAt, at: Date.now(), - ...(message ? { message } : {}) + ...(failure ? { message: failure.message } : {}), + // The plan refused it: the card says why (and offers plans), never the raw code. + ...(failure?.message === 'entitlement_denied' + ? { billingDenial: { reason: failure.reason ?? null } } + : {}) }) } } @@ -69,7 +76,7 @@ export async function wakePairedCloudWorkspace( if (result.ok) { report('ready') } else { - report('failed', result.error) + report('failed', { message: result.error, ...(result.reason ? { reason: result.reason } : {}) }) } await refreshCloudWorkspaceStatus(workspaceId) return result.ok diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index 70b49baf94d..ec1915d219d 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -44,6 +44,8 @@ export type CloudWakeProgressEvent = { startedAt: number at: number message?: string + /** The plan refused it (billing's deny reason, null when unsaid): the card says why, not the code. */ + billingDenial?: { reason: string | null } } export type CloudOomEvent = {