mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user