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 = {