feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761)

* feat(desktop): native in-app downgrade (chargeless preview → schedule → undo)

Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce
to the portal — picking a lower tier runs the gateway pending-change flow in-app;
the scheduled state renders on the plan card with an undo. Upgrades keep the portal
deep link.

- api.ts: add previewSubscriptionChange / scheduleSubscriptionChange /
  resumeSubscription wrappers over subscription.preview|change|resume
  ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse +
  BillingMutationResponse (now re-exported from types.ts).
- use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule,
  refetch + onScheduled on success; typed refusals surface via the shared
  BillingRefusalInline, so insufficient_scope drives the existing step-up exactly
  like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo).
  Both accept a `simulate` switch so DEV fixtures click through with canned success.
- plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an
  in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect
  <date>. No charge now; you keep your current plan until then."). The scheduled
  downgrade target renders an inert "Scheduled" marker; other lower tiers stay
  actionable (picking one reschedules).
- CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to
  <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps.
- use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the
  ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target
  by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView
  gains `pending`, derived from current.pending_downgrade_* .
- inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline /
  StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware
  refusal renderer without a circular import; openExternal now delegates to the
  canonical @/lib/external-link opener.
- dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free
  downgrade scheduled for Aug 15) for the plan-card pending state + grid marker.

Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume +
insufficient_scope refusal); view derivation (pending plan-card state, scheduled
grid marker); confirm flow (preview shown, change called with the right tier_id,
refetch on success, schedule refusal → step-up affordance); undo flow; the
use-subscription-change hooks (preview-refusal retry, cancel, simulate path).
Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck
(app/electron/e2e) + lint clean.

PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges.

* fix(desktop): format downgrade credits delta as signed dollars

The downgrade preview rendered the raw wire string ("Monthly credits change:
-88."), violating the "monthly credits are DOLLARS" ruling. NAS sends
monthly_credits_delta as a bare decimal; format it as signed dollars through the
same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero /
absent still hides the line.

Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/
absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm
flow. Billing suite 99/99 green; typecheck + lint clean.

* fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim

Addresses the adversarial review of the native-downgrade diff.

- Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule
  RPC is in flight). While a change commits, every other Downgrade tile and the
  Back button are disabled; the active panel's Confirm/Cancel already lock. The
  plan-card Undo blocks on its own resume via `busy`. (The server also 409s
  overlapping per-org mutations — this is UI honesty, not the only defense.)
- Accessibility: the confirm panel is role="status" aria-live="polite" and takes
  focus on open (tabIndex=-1 container); closing it returns focus to the tile card,
  so keyboard focus is never stranded and the async preview text is announced.
- DEV-gated simulation: the canned preview/change/resume seam is ignored unless
  import.meta.env.DEV, so a production build never takes the simulated branch even
  if a `simulate` prop leaks through.
- Comments: documented the deliberate manual-retry-after-step-up (no auto-replay,
  matching auto-reload) and that name-matching the scheduled target is safe because
  SubscriptionTypes.name is @unique in NAS.

Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule;
simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule;
Undo disabled mid-resume; confirm panel role + focus on open. typecheck
(app/electron/e2e) + lint clean.

* fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits

Addresses the native-downgrade review threads.

Scheduled cancellations were invisible (NEW review item). subscription.current
carries cancel_at_period_end + cancellation_effective_* and subscription.resume
clears cancellations exactly like downgrades, but the pending-transition helper only
read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain
renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName,
when } | { kind:'cancellation', when } — computed once in deriveBillingView and
threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with
the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a
cancellation has no target tier). Precedence: a downgrade wins if both fields are set
(it names a concrete target — the stronger signal), commented at the helper. Adds a
`pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker,
downgrade-wins precedence).

Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow)
so two same-tick clicks — before React commits busy='schedule' — cannot fire two
schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success).
useResumeFlow reorders its unlock: a refusal releases immediately, a success holds
runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending
card. Test: a synchronous double-activation fires one schedule RPC.

Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) instead of relying on outer narrowing / `?? ''`.

Billing suite green (109); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): move DEV billing simulation behind the api seam

The fixture simulation lived as `simulate` / `simulateResume` prop drills and
`if (simulated)` branches inside the flow hooks, and it could not actually produce
the state it advertised (a simulated schedule never showed the pending card).

Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi
built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known,
and supplied to the whole subtree via a new `BillingApiProvider` (context override on
`useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy
of the fixture and its subscription-change mutations WRITE that copy's pending state:
schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation.
Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted;
queries always enabled; an effect refetches on fixture switch), so the click-through
genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared.

Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every
`if (simulated)` branch — the hooks are now production-pure. Added a test driving the
full simulated loop (schedule → pending appears → resume → cleared), plus cancellation
undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests.

Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): extract billing row/card components out of index.tsx

Purely mechanical, no behavior change: split the settings billing route file
(1065 → 593 lines) into focused siblings now that the downgrade feature has settled
their final shape.

- billing-amounts.ts — the dollar parse/format/validate/clamp helpers.
- account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow).
- current-plan-card.tsx — CurrentPlanCard.
- auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor).

index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture
wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules

Polish that composes with the api-seam rework:

- ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union
  (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible
  combinations (a preview AND a refusal, "ready" with no quote) can no longer be
  represented; the hook and panel branch on one `kind`, and `mutating` is simply
  `phase.kind === 'scheduling'`.
- The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase,
  fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`.
- inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal`
  moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s
  `openExternalLink`), and `InlineMessage` moves back into its sole consumer
  (auto-reload-row.tsx).

No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.
This commit is contained in:
Siddharth Balyan 2026-07-22 08:16:06 +05:30 committed by GitHub
parent 9baad4e0aa
commit 60ec6a3b8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1786 additions and 643 deletions

View file

@ -0,0 +1,48 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { Pill } from '../primitives'
import { openExternal } from './open-external'
import type { BillingAccountRowView } from './use-billing-state'
export function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
// Destructure to a const so narrowing survives into the onClick closure below.
const { action } = row
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.value}
</span>
)}
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}
{action && (
<Button
disabled={action.disabled}
onClick={action.disabled ? undefined : onAction ? onAction : () => openExternal(action.url)}
size="sm"
type="button"
variant="outline"
>
{action.label}
{!action.disabled && action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
)
}

View file

@ -133,6 +133,48 @@ describe('createBillingApi', () => {
})
})
it('previews a subscription change with the chosen tier id', async () => {
requestGatewayMock.mockResolvedValueOnce({ effect: 'scheduled', ok: true, target_tier_name: 'Plus' })
const api = createBillingApi(requestGatewayMock)
const response = await api.previewSubscriptionChange('tier_plus')
expect(response).toEqual({ data: { effect: 'scheduled', ok: true, target_tier_name: 'Plus' }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.preview', { subscription_type_id: 'tier_plus' })
})
it('schedules a subscription change with the chosen tier id', async () => {
requestGatewayMock.mockResolvedValueOnce({ message: 'Downgrade scheduled.', ok: true })
const api = createBillingApi(requestGatewayMock)
const response = await api.scheduleSubscriptionChange('tier_plus')
expect(response).toEqual({ data: { message: 'Downgrade scheduled.', ok: true }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.change', { subscription_type_id: 'tier_plus' })
})
it('resumes (undoes) a scheduled change with no params', async () => {
requestGatewayMock.mockResolvedValueOnce({ message: 'Change cancelled.', ok: true })
const api = createBillingApi(requestGatewayMock)
const response = await api.resumeSubscription()
expect(response).toEqual({ data: { message: 'Change cancelled.', ok: true }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.resume', {})
})
it('surfaces an insufficient_scope refusal from a subscription preview', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: { kind: 'insufficient_scope', message: 'billing:manage required' },
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.scheduleSubscriptionChange('tier_plus')
expect(response).toMatchObject({ ok: false, refusal: { kind: 'insufficient_scope' } })
})
it('sends a step-up session id when provided', async () => {
requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true })

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { createContext, useContext, useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
@ -9,6 +9,7 @@ import type {
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse
} from './types'
@ -48,6 +49,12 @@ export interface BillingApi {
chargeStatus: (chargeId: string) => Promise<BillingResult<BillingChargeStatusResponse>>
fetchBillingState: () => Promise<BillingResult<BillingStateResponse>>
fetchSubscriptionState: () => Promise<BillingResult<SubscriptionStateResponse>>
/** Chargeless quote for a plan change (POST /subscription/preview). */
previewSubscriptionChange: (tierId: string) => Promise<BillingResult<SubscriptionPreviewResponse>>
/** Clear a scheduled downgrade / cancellation — the undo (DELETE pending-change). */
resumeSubscription: () => Promise<BillingResult<BillingMutationResponse>>
/** Schedule a chargeless downgrade at period end (PUT pending-change). */
scheduleSubscriptionChange: (tierId: string) => Promise<BillingResult<BillingMutationResponse>>
stepUp: (sessionId?: string) => Promise<BillingResult<BillingMutationResponse>>
updateAutoReload: (input: UpdateAutoReloadInput) => Promise<BillingResult<BillingMutationResponse>>
}
@ -149,6 +156,15 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
callBilling<BillingChargeStatusResponse>(requestGateway, 'billing.charge_status', { charge_id: chargeId }),
fetchBillingState: () => callBilling<BillingStateResponse>(requestGateway, 'billing.state'),
fetchSubscriptionState: () => callBilling<SubscriptionStateResponse>(requestGateway, 'subscription.state'),
previewSubscriptionChange: tierId =>
callBilling<SubscriptionPreviewResponse>(requestGateway, 'subscription.preview', {
subscription_type_id: tierId
}),
resumeSubscription: () => callBilling<BillingMutationResponse>(requestGateway, 'subscription.resume', {}),
scheduleSubscriptionChange: tierId =>
callBilling<BillingMutationResponse>(requestGateway, 'subscription.change', {
subscription_type_id: tierId
}),
stepUp: sessionId =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.step_up', {
...(sessionId !== undefined ? { session_id: sessionId } : {})
@ -161,8 +177,17 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
})
})
export function useBillingApi(): BillingApi {
const { requestGateway } = useGatewayRequest()
// An override for the gateway-backed api — DEV fixtures provide a simulated
// implementation here so every consumer (hooks, rows) transparently runs against it
// with no fixture awareness of their own. `null` (the default) = the real gateway api.
const BillingApiContext = createContext<BillingApi | null>(null)
return useMemo(() => createBillingApi(requestGateway), [requestGateway])
export const BillingApiProvider = BillingApiContext.Provider
export function useBillingApi(): BillingApi {
const override = useContext(BillingApiContext)
const { requestGateway } = useGatewayRequest()
const real = useMemo(() => createBillingApi(requestGateway), [requestGateway])
return override ?? real
}

View file

@ -0,0 +1,290 @@
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { ListRow, Pill } from '../primitives'
import { RowValue } from './account-row-value'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
import { BillingRefusalInline } from './inline-feedback'
import type { BillingAutoReload, BillingStateResponse } from './types'
import type { BillingAccountRowView } from './use-billing-state'
export function AutoReloadRow({
autoReload,
bounds,
row
}: {
autoReload: BillingAutoReload
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
// Validation errors are silent until the user edits a field or attempts a
// save — opening Manage on a prefilled (possibly below-min) config must not
// flash an error (spec §9).
const [showErrors, setShowErrors] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const [reloadTo, setReloadTo] = useState(
initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
// Only the canonical-card enabled state edits in place (flagged in the view model).
// Off / divergent-card rows have no Manage affordance (or a portal link) and render
// read-only.
const editable = row.manageInApp === true
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const openEdit = () => {
resetFeedback()
setShowErrors(false)
setEditing(true)
}
const cancelEdit = () => {
resetFeedback()
setEditing(false)
}
const save = async () => {
if (!validation.values || busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({
enabled: true,
reload_to_usd: validation.values.reloadTo,
threshold_usd: validation.values.threshold
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setEditing(false)
}
const disable = async () => {
if (busy) {
return
}
resetFeedback()
setSaving(true)
// The gateway's billing.auto_reload handler unconditionally requires threshold
// + top_up_amount (invalid_request otherwise), so a disable must still carry the
// current amounts — mirroring the TUI, which always sends both.
const result = await api.updateAutoReload({
enabled: false,
reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setEditing(false)
}
// Read-only states (off / divergent card) keep the original ListRow shape.
if (!editable) {
return (
<ListRow
action={<RowValue row={row} />}
below={
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
resetFeedback()
setShowErrors(true)
setter(event.target.value)
}
// Zero-shift by exact reservation, not a magic min-height: the edit form is
// ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
// so the row's height always equals the tallest state at EVERY container width —
// no breakpoint math that under-reserves when the two inputs stack on narrow
// panes. The form is `invisible` + `aria-hidden` when not editing.
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-start">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{row.description}
</div>
<div className="mt-3 grid [grid-template-areas:'stack']">
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
<div
aria-hidden={!editing}
className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}
>
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setThreshold)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setReloadTo)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={reloadTo}
/>
</label>
</div>
{/* Pre-allocated error line — occupies height whether or not shown. */}
<div className="min-h-4 text-[length:var(--conversation-caption-font-size)] text-destructive">
{showErrors && validation.error ? validation.error : ''}
</div>
{confirmDisable ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(false)} size="sm" type="button" variant="ghost">
Cancel
</Button>
</div>
) : (
<Button
disabled={busy}
onClick={() => setConfirmDisable(true)}
size="sm"
tabIndex={editing ? undefined : -1}
type="button"
variant="outline"
>
Disable
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
<BillingRefusalInline refusal={refusal} />
</div>
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */}
{!editing && message && (
<div className="[grid-area:stack]">
<InlineMessage kind={message.kind}>{message.text}</InlineMessage>
</div>
)}
</div>
</div>
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
</Button>
)}
</div>
</div>
</div>
)
}
// A one-line success/error note under the row — the only consumer of this shape.
function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) {
return (
<div
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)]',
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
)}
>
{children}
</div>
)
}

View file

@ -0,0 +1,123 @@
import type { BillingStateResponse } from './types'
import { EMPTY_BILLING_VALUE } from './use-billing-state'
export function clampAmount(raw: string, billing: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>): string {
const amount = parseAmount(raw)
if (amount == null) {
return ''
}
const min = parseAmount(billing.min_usd)
const max = parseAmount(billing.max_usd)
const clampedMin = min == null ? amount : Math.max(min, amount)
const clamped = max == null ? clampedMin : Math.min(max, clampedMin)
return formatAmountForRequest(clamped)
}
export function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
export function formatAmountForRequest(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
export function initialAutoReloadAmount(...candidates: Array<null | string | undefined>): string {
for (const candidate of candidates) {
const amount = parseAmount(candidate)
if (amount != null) {
return formatAmountForRequest(amount)
}
}
return ''
}
export function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
}
return {
values: {
reloadTo: formatAmountForRequest(reloadTo.amount),
threshold: formatAmountForRequest(threshold.amount)
}
}
}
export function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
}
return { amount }
}
export function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
return new Intl.NumberFormat(undefined, {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}

View file

@ -0,0 +1,57 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { BillingRefusalInline } from './inline-feedback'
import { openExternal } from './open-external'
import { TierArt } from './tier-art'
import type { BillingPlanCardView } from './use-billing-state'
import { useResumeFlow } from './use-subscription-change'
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
const resumeFlow = useResumeFlow()
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-3">
<TierArt name={plan.tierName} />
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{plan.tierName}
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.caption}
</div>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{plan.action && (
<Button onClick={onViewPlans} size="sm" type="button" variant="outline">
{plan.action.label}
</Button>
)}
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
{plan.pending && (
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
</Button>
)}
{plan.link && (
<Button onClick={() => plan.link && openExternal(plan.link.url)} size="sm" type="button" variant="outline">
{plan.link.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
</div>
<BillingRefusalInline refusal={resumeFlow.refusal} />
</div>
)
}

View file

@ -311,6 +311,40 @@ export const subscriberPersonalSubscriptionState = {
usage: subscriberPersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus with a downgrade to Free already scheduled at period
// end: exercises the plan-card pending state + undo, and the grid's "Scheduled"
// marker on Free while Super/Ultra stay choosable.
export const pendingDowngradeSubscriptionState = {
...subscriberPersonalSubscriptionState,
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
pending_downgrade_at: '2026-08-15T00:00:00Z',
pending_downgrade_display: 'Aug 15',
pending_downgrade_tier_name: 'Free',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
})
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus with a cancellation (not a downgrade) scheduled at
// period end: exercises the plan-card "Cancels on …" copy + undo, with NO Scheduled
// grid marker (a cancellation has no target tier).
export const pendingCancellationSubscriptionState = {
...subscriberPersonalSubscriptionState,
current: current({
cancel_at_period_end: true,
cancellation_effective_at: '2026-08-15T00:00:00Z',
cancellation_effective_display: 'Aug 15',
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
})
} satisfies SubscriptionStateResponse
const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
@ -382,6 +416,14 @@ export const billingDevFixtures = {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(subscriberPersonalSubscriptionState)
},
'pending-cancellation': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(pendingCancellationSubscriptionState)
},
'pending-downgrade': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(pendingDowngradeSubscriptionState)
},
'auto-refill-divergent': withUsage('Auto Refill Divergent', {
autoReload: {
...postTrainBillingState.auto_reload,

View file

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -24,16 +25,24 @@ const apiMocks = vi.hoisted(() => ({
fetchBillingState: vi.fn(),
fetchSubscriptionState: vi.fn(),
openExternal: vi.fn(),
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn(),
stepUp: vi.fn(),
updateAutoReload: vi.fn()
}))
vi.mock('./api', () => ({
// Pass-through provider — the mocked useBillingApi ignores any override anyway.
BillingApiProvider: ({ children }: { children: ReactNode }) => children,
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus,
fetchBillingState: apiMocks.fetchBillingState,
fetchSubscriptionState: apiMocks.fetchSubscriptionState,
previewSubscriptionChange: apiMocks.previewSubscriptionChange,
resumeSubscription: apiMocks.resumeSubscription,
scheduleSubscriptionChange: apiMocks.scheduleSubscriptionChange,
stepUp: apiMocks.stepUp,
updateAutoReload: apiMocks.updateAutoReload
})
@ -236,7 +245,7 @@ describe('BillingSettings', () => {
expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy()
})
it('renders the current marker and disabled downgrade when deep-linked to the plans grid', async () => {
it('renders the current marker and an actionable downgrade when deep-linked to the plans grid', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
@ -245,9 +254,8 @@ describe('BillingSettings', () => {
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Current plan')).toBeTruthy()
// Free sits below Plus → disabled downgrade with the ticket-11 caption.
expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByText('Downgrades are moving in-app — coming soon.')).toBeTruthy()
// Free sits below Plus → an in-app (enabled) "Downgrade" button, not disabled.
expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(false)
// Super + Ultra are upgrades.
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(2)
})
@ -274,44 +282,194 @@ describe('BillingSettings', () => {
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a top-tier subscriber deep-links bview=plans', async () => {
// Capable, but on the highest tier → no upgrade → no in-app button → the deep
// link must not open a grid whose only actions are inert downgrades.
it('runs an in-app downgrade: preview → confirm → schedule with the tier id → refetch → overview', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: {
effect: 'scheduled',
effective_at: '2026-08-15T00:00:00Z',
monthly_credits_delta: '-88',
ok: true,
target_tier_name: 'Free'
},
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling(['/settings?tab=billing&bview=plans'])
const invalidate = vi.spyOn(client, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
await waitFor(() =>
expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal')
)
expect(await screen.findByText(/No charge now/)).toBeTruthy()
// Credits delta renders as signed dollars, not the raw wire string "-88".
expect(screen.getByText(/Monthly credits change: \$88\/mo\./)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Confirm downgrade' }))
await waitFor(() =>
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal')
)
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
// Scheduled → back on the overview.
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
})
it('surfaces the step-up affordance when scheduling a downgrade needs approval', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
// The failed schedule offers a retry in place.
expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy()
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
it('undoes a scheduled downgrade from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
expect(await screen.findByText('Changes to Free on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
})
it('undoes a scheduled cancellation from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-cancellation']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
renderBilling()
expect(await screen.findByText('Cancels on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
})
it('locks out the other downgrade tiles and Back while a schedule is in flight', async () => {
// Current = Ultra so Free/Plus/Super are all downgrades (three tiles).
apiMocks.fetchBillingState.mockResolvedValue(billingDevFixtures['subscriber-personal'].billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' },
current: { ...todaySubscriptionState.current, tier_id: 't_ultra', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'top',
tier_order: 1
}
{ dollars_per_month_display: '$0', is_current: false, is_enabled: true, monthly_credits: '0.1', name: 'Free', tier_id: 't_free', tier_order: 0 },
{ dollars_per_month_display: '$20', is_current: false, is_enabled: true, monthly_credits: '22', name: 'Plus', tier_id: 't_plus', tier_order: 1 },
{ dollars_per_month_display: '$100', is_current: false, is_enabled: true, monthly_credits: '110', name: 'Super', tier_id: 't_super', tier_order: 2 },
{ dollars_per_month_display: '$200', is_current: true, is_enabled: true, monthly_credits: '220', name: 'Ultra', tier_id: 't_ultra', tier_order: 3 }
]
})
)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
// The plan card's portal link is present instead of an in-app button.
expect(screen.getByRole('button', { name: /Adjust plan/ })).toBeTruthy()
const downgrades = await screen.findAllByRole('button', { name: 'Downgrade' })
expect(downgrades.length).toBe(3)
fireEvent.click(downgrades[0])
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
// Scheduling in flight → the two remaining tiles + Back are disabled.
await waitFor(() => {
const remaining = screen.getAllByRole('button', { name: 'Downgrade' })
expect(remaining.length).toBe(2)
expect(remaining.every(btn => btn.hasAttribute('disabled'))).toBe(true)
})
expect(screen.getByRole('button', { name: 'Back to billing' }).hasAttribute('disabled')).toBe(true)
settleSchedule({ data: { ok: true }, ok: true })
})
it('disables Undo while the resume is in flight', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
let settleResume: (value: unknown) => void = () => {}
apiMocks.resumeSubscription.mockReturnValue(
new Promise(resolve => {
settleResume = resolve
})
)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Undo' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Undoing…' }).hasAttribute('disabled')).toBe(true))
settleResume({ data: { ok: true }, ok: true })
})
it('moves focus into the confirm panel (role=status) when a downgrade opens', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
const panel = await screen.findByRole('status')
expect(panel.getAttribute('aria-live')).toBe('polite')
expect(panel).toBe(panel.ownerDocument.activeElement)
})
it('keeps the auto-refill edit form mounted so the row height is reserved before editing', async () => {

View file

@ -9,33 +9,35 @@ import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/i
import { cn } from '@/lib/utils'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives'
import { ListRow, SectionHeading, SettingsContent } from '../primitives'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
import { AutoReloadRow } from './auto-reload-row'
import { clampAmount, formatMoney } from './billing-amounts'
import { CurrentPlanCard } from './current-plan-card'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
import { resolveRefusal } from './errors'
import { StepUpInlineAction } from './inline-feedback'
import { openExternal } from './open-external'
import { BillingPlansView } from './plans-view'
import { TierArt } from './tier-art'
import type { BillingAutoReload, BillingStateResponse } from './types'
import { createSimulatedBillingApi } from './simulated-api'
import type { BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingPlanCardView,
type BillingUsageRowView,
deriveBillingView,
EMPTY_BILLING_VALUE,
formatUsageUpdatedAgo,
useBillingState,
useSubscriptionState
} from './use-billing-state'
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
// `bview` mirrors the settings pview/kview sub-view pattern (deep-linkable, replace
// navigation). `overview` is the default landing; `plans` is the in-app catalog.
const BILLING_VIEWS = ['overview', 'plans'] as const
type BillingSubView = (typeof BILLING_VIEWS)[number]
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
const FEATURE_BILLING_INVOICES = false
@ -45,14 +47,6 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
type BillingFixtureSelection = 'live' | BillingDevFixtureName
function openExternal(url?: string) {
if (!url) {
return
}
void window.hermesDesktop?.openExternal?.(url)
}
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
<div className="min-w-0">
@ -92,47 +86,6 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
)
}
function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
// Destructure to a const so narrowing survives into the onClick closure below.
const { action } = row
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.value}
</span>
)}
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}
{action && (
<Button
disabled={action.disabled}
onClick={action.disabled ? undefined : onAction ? onAction : () => openExternal(action.url)}
size="sm"
type="button"
variant="outline"
>
{action.label}
{!action.disabled && action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
)
}
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
return <BuyCreditsRow billing={billing} row={row} />
@ -159,306 +112,6 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil
)
}
function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-3">
<TierArt name={plan.tierName} />
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{plan.tierName}
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.caption}
</div>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{plan.action && (
<Button onClick={onViewPlans} size="sm" type="button" variant="outline">
{plan.action.label}
</Button>
)}
{plan.link && (
<Button onClick={() => plan.link && openExternal(plan.link.url)} size="sm" type="button" variant="outline">
{plan.link.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
)
}
function AutoReloadRow({
autoReload,
bounds,
row
}: {
autoReload: BillingAutoReload
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
// Validation errors are silent until the user edits a field or attempts a
// save — opening Manage on a prefilled (possibly below-min) config must not
// flash an error (spec §9).
const [showErrors, setShowErrors] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const [reloadTo, setReloadTo] = useState(
initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
// Only the canonical-card enabled state edits in place (flagged in the view model).
// Off / divergent-card rows have no Manage affordance (or a portal link) and render
// read-only.
const editable = row.manageInApp === true
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const openEdit = () => {
resetFeedback()
setShowErrors(false)
setEditing(true)
}
const cancelEdit = () => {
resetFeedback()
setEditing(false)
}
const save = async () => {
if (!validation.values || busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({
enabled: true,
reload_to_usd: validation.values.reloadTo,
threshold_usd: validation.values.threshold
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setEditing(false)
}
const disable = async () => {
if (busy) {
return
}
resetFeedback()
setSaving(true)
// The gateway's billing.auto_reload handler unconditionally requires threshold
// + top_up_amount (invalid_request otherwise), so a disable must still carry the
// current amounts — mirroring the TUI, which always sends both.
const result = await api.updateAutoReload({
enabled: false,
reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setEditing(false)
}
// Read-only states (off / divergent card) keep the original ListRow shape.
if (!editable) {
return (
<ListRow
action={<RowValue row={row} />}
below={
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
resetFeedback()
setShowErrors(true)
setter(event.target.value)
}
// Zero-shift by exact reservation, not a magic min-height: the edit form is
// ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
// so the row's height always equals the tallest state at EVERY container width —
// no breakpoint math that under-reserves when the two inputs stack on narrow
// panes. The form is `invisible` + `aria-hidden` when not editing.
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-start">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{row.description}
</div>
<div className="mt-3 grid [grid-template-areas:'stack']">
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
<div
aria-hidden={!editing}
className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}
>
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setThreshold)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setReloadTo)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={reloadTo}
/>
</label>
</div>
{/* Pre-allocated error line — occupies height whether or not shown. */}
<div className="min-h-4 text-[length:var(--conversation-caption-font-size)] text-destructive">
{showErrors && validation.error ? validation.error : ''}
</div>
{confirmDisable ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(false)} size="sm" type="button" variant="ghost">
Cancel
</Button>
</div>
) : (
<Button
disabled={busy}
onClick={() => setConfirmDisable(true)}
size="sm"
tabIndex={editing ? undefined : -1}
type="button"
variant="outline"
>
Disable
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
<BillingRefusalInline refusal={refusal} />
</div>
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */}
{!editing && message && (
<div className="[grid-area:stack]">
<InlineMessage kind={message.kind}>{message.text}</InlineMessage>
</div>
)}
</div>
</div>
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
</Button>
)}
</div>
</div>
</div>
)
}
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
const presets = useMemo(
() =>
@ -622,82 +275,6 @@ function BuyCreditsOutcome({
)
}
function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
<span className="font-medium text-foreground">{resolved.title}:</span> {resolved.message}
</span>
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
<ExternalLink className="size-3.5" />
</Button>
</span>
)
}
if (flow.message) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span>
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
</Button>
)
}
function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) {
return (
<div
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)]',
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
)}
>
{children}
</div>
)
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
@ -876,15 +453,15 @@ function BillingSettingsContent({
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const fixture =
import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
const billingState = useBillingState(!fixture)
const subscriptionState = useSubscriptionState(!fixture)
const billingResult = fixture?.billing ?? billingState.data
const subscriptionResult = fixture?.subscription ?? subscriptionState.data
// Fixture mode flows through the SAME query path — the simulated api (supplied by
// BillingApiProvider in the DEV wrapper) backs these fetches — so there is no
// fixture short-circuit here.
const billingState = useBillingState()
const subscriptionState = useSubscriptionState()
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
@ -979,8 +556,27 @@ function BillingSettingsContent({
function BillingSettingsWithDevFixtures() {
const [fixtureName, setFixtureName] = useState<BillingFixtureSelection>('live')
const queryClient = useQueryClient()
return <BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
// DEV-only: a picked fixture is served by a simulated api (in-memory, mutable) that
// the whole subtree resolves via BillingApiProvider → useBillingApi. `live` → null →
// the real gateway api. Rebuilt per fixture so switching starts from a fresh copy.
const simulatedApi = useMemo(
() => (fixtureName !== 'live' ? createSimulatedBillingApi(billingDevFixtures[fixtureName]) : null),
[fixtureName]
)
// Switching fixtures (or its simulated api) must refetch, since the billing queries
// are keyed the same across fixtures.
useEffect(() => {
void queryClient.invalidateQueries({ queryKey: ['billing'] })
}, [queryClient, simulatedApi])
return (
<BillingApiProvider value={simulatedApi}>
<BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
</BillingApiProvider>
)
}
export function BillingSettings() {
@ -991,129 +587,8 @@ export function BillingSettings() {
return <BillingSettingsContent />
}
function clampAmount(raw: string, billing: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>): string {
const amount = parseAmount(raw)
if (amount == null) {
return ''
}
const min = parseAmount(billing.min_usd)
const max = parseAmount(billing.max_usd)
const clampedMin = min == null ? amount : Math.max(min, amount)
const clamped = max == null ? clampedMin : Math.min(max, clampedMin)
return formatAmountForRequest(clamped)
}
function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function formatAmountForRequest(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
function oldestUpdatedAt(...timestamps: number[]): number {
const populated = timestamps.filter(timestamp => timestamp > 0)
return populated.length > 0 ? Math.min(...populated) : Date.now()
}
function initialAutoReloadAmount(...candidates: Array<null | string | undefined>): string {
for (const candidate of candidates) {
const amount = parseAmount(candidate)
if (amount != null) {
return formatAmountForRequest(amount)
}
}
return ''
}
function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
}
return {
values: {
reloadTo: formatAmountForRequest(reloadTo.amount),
threshold: formatAmountForRequest(threshold.amount)
}
}
}
function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
}
return { amount }
}
function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
return new Intl.NumberFormat(undefined, {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}

View file

@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ExternalLink } from '@/lib/icons'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
import { useStepUpFlow } from './use-step-up'
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
<ExternalLink className="size-3.5" />
</Button>
</span>
)
}
if (flow.message) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span>
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
</Button>
)
}
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
<span className="font-medium text-foreground">{resolved.title}:</span> {resolved.message}
</span>
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}

View file

@ -0,0 +1,9 @@
import { openExternalLink } from '@/lib/external-link'
// Optional-arg convenience over the canonical opener — the billing rows pass
// possibly-undefined URLs straight through from their view models.
export function openExternal(url?: string) {
if (url) {
openExternalLink(url)
}
}

View file

@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink } from '@/lib/icons'
@ -5,18 +7,129 @@ import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
import type { BillingPlanTierView } from './use-billing-state'
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
import { type DowngradePhase, useDowngradeFlow } from './use-subscription-change'
function PlanCard({ tier }: { tier: BillingPlanTierView }) {
type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
// The human sentence for the panel body, derived purely from the phase. `null` while
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
function previewMessage(phase: DowngradePhase, fallbackTierName: string): null | string {
if (phase.kind === 'previewing') {
return 'Checking this change…'
}
if (phase.kind === 'previewFailed') {
return null
}
const { preview } = phase
const targetName = preview.target_tier_name ?? fallbackTierName
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta)
switch (preview.effect) {
case 'blocked':
return preview.reason ?? 'That change cannot be made here.'
case 'no_op':
return `You are already on ${targetName} — nothing to change.`
case 'scheduled':
return (
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
)
default:
return 'This change cannot be scheduled here.'
}
}
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const active = flow.active
const panelRef = useRef<HTMLDivElement>(null)
const open = active?.target.tierId === tier.tierId
// Move focus into the panel on open so keyboard users land on the confirm flow;
// role="status"/aria-live announces the async preview text as it arrives.
useEffect(() => {
if (open) {
panelRef.current?.focus()
}
}, [open])
if (!active || active.target.tierId !== tier.tierId) {
return null
}
const { phase } = active
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
const message = previewMessage(phase, tier.name)
const canConfirm =
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
phase.kind === 'scheduling' ||
phase.kind === 'scheduleFailed'
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
>
{message && <div className={captionCn}>{message}</div>}
<BillingRefusalInline refusal={refusal} />
<div className="flex min-w-0 flex-wrap items-center gap-2">
{phase.kind === 'previewFailed' ? (
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
Try again
</Button>
) : canConfirm ? (
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
{phase.kind === 'scheduling' ? 'Scheduling…' : phase.kind === 'scheduleFailed' ? 'Try again' : 'Confirm downgrade'}
</Button>
) : null}
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
Cancel
</Button>
</div>
</div>
)
}
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const isCurrent = tier.state === 'current'
const confirming = flow.active?.target.tierId === tier.tierId
const cardRef = useRef<HTMLDivElement>(null)
const wasConfirming = useRef(false)
// When the confirm panel closes (cancel / scheduled), return focus to this tile
// so keyboard focus is never left detached on the removed panel.
useEffect(() => {
if (wasConfirming.current && !confirming) {
cardRef.current?.focus()
}
wasConfirming.current = confirming
}, [confirming])
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg border p-4',
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
)}
ref={cardRef}
tabIndex={-1}
>
<div className="flex min-w-0 items-center gap-3">
<TierArt name={tier.name} />
@ -39,6 +152,8 @@ function PlanCard({ tier }: { tier: BillingPlanTierView }) {
<div className="mt-auto min-w-0 pt-1">
{isCurrent && <Pill tone="primary">Current plan</Pill>}
{tier.state === 'scheduled' && <Pill>Scheduled</Pill>}
{tier.state === 'upgrade' && (
<Button onClick={() => tier.action && openExternalLink(tier.action.url)} size="sm" type="button" variant="outline">
{tier.action.label}
@ -46,28 +161,38 @@ function PlanCard({ tier }: { tier: BillingPlanTierView }) {
</Button>
)}
{tier.state === 'downgrade' && (
<div className="flex min-w-0 flex-col gap-1.5">
<Button disabled size="sm" type="button" variant="outline">
{tier.state === 'downgrade' &&
(confirming ? (
<DowngradeConfirm flow={flow} tier={tier} />
) : (
// Disabled while another tile's change is committing — no concurrent mutation.
<Button
disabled={flow.mutating}
onClick={() => flow.begin({ tierId: tier.tierId, tierName: tier.name })}
size="sm"
type="button"
variant="outline"
>
Downgrade
</Button>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.disabledCaption}
</span>
</div>
)}
))}
</div>
</div>
)
}
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
// A scheduled downgrade lands the user back on the overview, where the plan card
// now shows the pending state with its undo.
const flow = useDowngradeFlow({ onScheduled: onBack })
return (
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label="Back to billing"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={flow.mutating}
onClick={onBack}
size="sm"
type="button"
@ -81,7 +206,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
{tiers.length > 0 ? (
<div className="grid gap-3 @lg:grid-cols-2 @3xl:grid-cols-3">
{tiers.map(tier => (
<PlanCard key={tier.tierId} tier={tier} />
<PlanCard flow={flow} key={tier.tierId} tier={tier} />
))}
</div>
) : (

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { billingDevFixtures } from './dev-fixtures'
import { createSimulatedBillingApi } from './simulated-api'
import { deriveBillingView } from './use-billing-state'
const FREE_TIER_ID = 'cltier000free0000personal'
describe('createSimulatedBillingApi', () => {
it('progresses the pending state through the whole loop: schedule sets it, resume clears it', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
const billing = await api.fetchBillingState()
// Baseline: subscriber on Plus, nothing pending.
const before = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(before.plan?.pending).toBeUndefined()
// Schedule a downgrade to Free → the very next fetch shows the pending card + marker.
expect((await api.scheduleSubscriptionChange(FREE_TIER_ID)).ok).toBe(true)
const afterSchedule = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(afterSchedule.plan?.pending).toMatchObject({ kind: 'downgrade', tierName: 'Free' })
expect(afterSchedule.tiers.find(tier => tier.name === 'Free')?.state).toBe('scheduled')
// Undo → pending cleared on the next fetch.
expect((await api.resumeSubscription()).ok).toBe(true)
const afterResume = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(afterResume.plan?.pending).toBeUndefined()
expect(afterResume.tiers.some(tier => tier.state === 'scheduled')).toBe(false)
})
it('previews a chargeless scheduled change for the chosen tier', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
const preview = await api.previewSubscriptionChange(FREE_TIER_ID)
expect(preview).toMatchObject({ data: { effect: 'scheduled', target_tier_name: 'Free' }, ok: true })
})
it('undoes a scheduled cancellation too', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['pending-cancellation'])
const billing = await api.fetchBillingState()
expect(deriveBillingView(billing, await api.fetchSubscriptionState()).plan?.pending).toMatchObject({
kind: 'cancellation'
})
await api.resumeSubscription()
expect(deriveBillingView(billing, await api.fetchSubscriptionState()).plan?.pending).toBeUndefined()
})
it('does not mutate the shared fixture object', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
await api.scheduleSubscriptionChange(FREE_TIER_ID)
const fixture = billingDevFixtures['subscriber-personal']
expect(deriveBillingView(fixture.billing, fixture.subscription).plan?.pending).toBeUndefined()
})
})

View file

@ -0,0 +1,93 @@
import type { BillingApi, BillingResult } from './api'
import type {
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse
} from './types'
/** The shape of one `billingDevFixtures` entry — a canned billing + subscription pair. */
export interface SimulatedFixture {
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
// A visible-but-brief pause so the live fixture loop actually sees the "Checking…" /
// "Scheduling…" / "Undoing…" transitions rather than an instant flip.
const SIMULATED_DELAY_MS = 300
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const ok = <T>(data: T): BillingResult<T> => ({ data, ok: true })
/**
* A fully in-memory BillingApi for DEV fixtures no gateway. Fetches serve a mutable
* copy of the fixture, and the subscription-change mutations WRITE that copy's pending
* state, so the fixture click-through genuinely progresses: schedule sets a pending
* downgrade ( the plan card's "Changes to …" + Undo, and the grid's Scheduled marker
* on refetch), and resume clears any pending downgrade OR cancellation. Consumers reach
* it transparently via `useBillingApi` (overridden by BillingApiProvider), so no code
* outside this file is fixture-aware.
*/
export function createSimulatedBillingApi(fixture: SimulatedFixture): BillingApi {
const billing = fixture.billing
// Mutable copy so scheduling/undo don't leak back into the shared fixture object.
let subscription: BillingResult<SubscriptionStateResponse> = structuredClone(fixture.subscription)
const patchCurrent = (patch: Partial<NonNullable<SubscriptionStateResponse['current']>>) => {
if (subscription.ok && subscription.data.current) {
subscription = ok({ ...subscription.data, current: { ...subscription.data.current, ...patch } })
}
}
const tierName = (tierId: string): null | string =>
(subscription.ok ? subscription.data.tiers.find(tier => tier.tier_id === tierId)?.name : null) ?? null
return {
charge: async (_amountUsd, idempotencyKey = 'sim-key') => ({
data: { charge_id: 'sim-charge', ok: true },
idempotencyKey,
ok: true
}),
chargeStatus: async () => ok({ amount_usd: '0', ok: true, settled_at: null, status: 'settled' }),
fetchBillingState: async () => billing,
fetchSubscriptionState: async () => subscription,
previewSubscriptionChange: async tierId => {
await delay(SIMULATED_DELAY_MS)
const preview: SubscriptionPreviewResponse = {
effect: 'scheduled',
effective_at: subscription.ok ? (subscription.data.current?.cycle_ends_at ?? null) : null,
ok: true,
target_tier_name: tierName(tierId)
}
return ok(preview)
},
resumeSubscription: async () => {
await delay(SIMULATED_DELAY_MS)
// Undo either scheduled change kind.
patchCurrent({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null
})
return ok({ message: 'Change cancelled.', ok: true })
},
scheduleSubscriptionChange: async tierId => {
await delay(SIMULATED_DELAY_MS)
patchCurrent({
pending_downgrade_at: subscription.ok ? (subscription.data.current?.cycle_ends_at ?? null) : null,
pending_downgrade_display: null,
pending_downgrade_tier_name: tierName(tierId)
})
return ok({ message: 'Downgrade scheduled.', ok: true })
},
stepUp: async () => ok({ granted: true, ok: true }),
updateAutoReload: async () => ok({ ok: true })
}
}

View file

@ -9,6 +9,7 @@ import type {
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
@ -26,6 +27,7 @@ export type {
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,

View file

@ -13,7 +13,7 @@ import {
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { buildManageSubscriptionUrl, deriveBillingView } from './use-billing-state'
import { buildManageSubscriptionUrl, deriveBillingView, formatMonthlyCreditsDelta } from './use-billing-state'
function usageRowFor(
fixtureName: keyof typeof billingDevFixtures,
@ -367,9 +367,9 @@ describe('derivePlanCard (current-plan card)', () => {
})
it('gives a top-tier subscriber a portal link, not a dead in-app button', () => {
// The subscriber is on the highest enabled tier: the grid holds only downgrades +
// current — nothing to upgrade to — so the card must fall back to the portal link
// rather than open a grid with no enabled action.
// On the highest tier, every enabled tile below is a downgrade — but downgrades are
// themselves actionable in-app at ticket 11, so this stays a "Change plan" account.
// (The dead-grid case is a subscriber whose only tile is `current`; covered above.)
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
@ -401,8 +401,81 @@ describe('derivePlanCard (current-plan card)', () => {
)
expect(view.tiers.some(tier => tier.state === 'upgrade')).toBe(false)
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
// Free below is an in-app downgrade → still actionable → in-app button.
expect(view.plan?.action).toMatchObject({ label: 'Change plan' })
})
it('surfaces a scheduled downgrade as the plan-card pending state (drives the undo)', () => {
const fixture = billingDevFixtures['pending-downgrade']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({
action: { label: 'Change plan' },
caption: 'Changes to Free on Aug 15.',
pending: { kind: 'downgrade', tierName: 'Free', when: 'Aug 15' },
tierName: 'Plus'
})
})
it('surfaces a scheduled cancellation as "Cancels on …" with the same undo and no grid marker', () => {
const fixture = billingDevFixtures['pending-cancellation']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({
action: { label: 'Change plan' },
caption: 'Cancels on Aug 15.',
pending: { kind: 'cancellation', when: 'Aug 15' },
tierName: 'Plus'
})
// A cancellation has no target tier → nothing to mark in the grid.
expect(view.tiers.some(tier => tier.state === 'scheduled')).toBe(false)
})
it('lets a pending downgrade win over a cancellation when both are set', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: {
...todaySubscriptionState.current,
cancel_at_period_end: true,
cancellation_effective_at: '2026-09-01T00:00:00Z',
cancellation_effective_display: 'Sep 1',
pending_downgrade_at: '2026-08-15T00:00:00Z',
pending_downgrade_display: 'Aug 15',
pending_downgrade_tier_name: 'Free',
tier_id: 'plus',
tier_name: 'Plus'
},
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
}
]
})
)
// Downgrade wins (names a concrete target); card + grid agree on it.
expect(view.plan?.pending).toMatchObject({ kind: 'downgrade', tierName: 'Free' })
expect(view.plan?.caption).toBe('Changes to Free on Aug 15.')
expect(view.tiers.find(tier => tier.name === 'Free')?.state).toBe('scheduled')
})
it('offers only the portal link when the tier catalog is empty', () => {
@ -424,16 +497,14 @@ describe('derivePlanCard (current-plan card)', () => {
})
describe('derivePlanTiers (plans grid)', () => {
it('marks the current tier, upgrades, and disabled downgrades for a subscriber', () => {
it('marks the current tier, upgrades, and in-app downgrades for a subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Plus', 'Super', 'Ultra'])
expect(byName.Free).toMatchObject({
disabledCaption: 'Downgrades are moving in-app — coming soon.',
state: 'downgrade'
})
expect(byName.Free.state).toBe('downgrade')
// Downgrades act in-app (no portal URL / caption) — the PlanCard wires the confirm flow.
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
expect('action' in byName.Plus).toBe(false)
@ -447,6 +518,20 @@ describe('derivePlanTiers (plans grid)', () => {
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the pending downgrade target "scheduled" (inert) while other tiers stay actionable', () => {
const fixture = billingDevFixtures['pending-downgrade']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// Free is the scheduled target → inert marker, not another "Downgrade".
expect(byName.Free.state).toBe('scheduled')
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
// Reschedule stays possible on the other lower/higher tiers.
expect(byName.Super.state).toBe('upgrade')
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the free/lowest tier current (inert) and every paid tier an upgrade when there is no subscription', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
@ -455,7 +540,6 @@ describe('derivePlanTiers (plans grid)', () => {
// No "subscribe to Free" — the $0 tier is the current plan, not a choice.
expect(view.tiers.map(tier => tier.state)).toEqual(['current', 'upgrade', 'upgrade', 'upgrade'])
expect('action' in byName.Free).toBe(false)
expect('disabledCaption' in byName.Free).toBe(false)
// No downgrade state can exist without a subscription.
expect(view.tiers.some(tier => tier.state === 'downgrade')).toBe(false)
expect(byName.Plus).toMatchObject({
@ -657,3 +741,22 @@ describe('buildManageSubscriptionUrl', () => {
)
})
})
describe('formatMonthlyCreditsDelta', () => {
it('renders a bare negative decimal as signed dollars, never a raw number', () => {
// Credits are DOLLARS — "-88" must not render bare.
expect(formatMonthlyCreditsDelta('-88')).toBe('$88/mo')
})
it('renders a positive delta with a plus sign and dollar formatting', () => {
expect(formatMonthlyCreditsDelta('40')).toBe('+$40/mo')
expect(formatMonthlyCreditsDelta('-12.50')).toBe('$12.50/mo')
})
it('hides the line (null) for a zero or absent delta', () => {
expect(formatMonthlyCreditsDelta('0')).toBeNull()
expect(formatMonthlyCreditsDelta(null)).toBeNull()
expect(formatMonthlyCreditsDelta(undefined)).toBeNull()
expect(formatMonthlyCreditsDelta('')).toBeNull()
})
})

View file

@ -67,6 +67,15 @@ export interface BillingAccountRowView {
value?: string
}
/**
* A change scheduled at period end that `subscription.resume` can undo. A downgrade
* names its target tier (and marks it in the grid); a cancellation has no target
* (the whole plan lapses), so the grid shows no marker for it.
*/
export type PendingPlanTransition =
| { kind: 'cancellation'; when: string }
| { kind: 'downgrade'; tierName: string; when: string }
/**
* The current-plan summary that replaces the old subscription row. Carries EITHER
* one in-app `action` (View plans / Change plan) OR a portal `link` ("Adjust plan
@ -75,6 +84,8 @@ export interface BillingAccountRowView {
*/
export type BillingPlanCardView = {
caption: string
/** A scheduled downgrade / cancellation waiting at period end (drives the undo). */
pending?: PendingPlanTransition
price?: string
tierName: string
} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } })
@ -87,14 +98,15 @@ interface BillingPlanTierBase {
}
/**
* One card in the `bview=plans` grid, discriminated by `state`: an `upgrade` always
* carries its portal `action`; a `downgrade` always carries a `disabledCaption`
* (ticket 11 wires these in-app); `current` is inert. The union lets consumers read
* `action` / `disabledCaption` without defensive `?.`.
* One card in the `bview=plans` grid, discriminated by `state`: `upgrade` carries its
* portal `action`; `downgrade` is actionable IN-APP (the flow keys off `tierId`, so it
* needs no url/caption); `scheduled` is the inert pending-downgrade target; `current`
* is inert. The union lets consumers read `action` without defensive `?.`.
*/
export type BillingPlanTierView =
| (BillingPlanTierBase & { state: 'current' })
| (BillingPlanTierBase & { disabledCaption: string; state: 'downgrade' })
| (BillingPlanTierBase & { state: 'downgrade' })
| (BillingPlanTierBase & { state: 'scheduled' })
| (BillingPlanTierBase & { action: { label: string; url: string }; state: 'upgrade' })
export interface BillingUsageRowView {
@ -194,12 +206,15 @@ export function deriveBillingView(
// link) and the grid (whether upgrade tiles are actionable) so the invariant lives
// in one place.
const capable = plansCapable(subscription, subscriptionResult)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable)
// Computed once and threaded to both the card (caption + undo) and the grid
// (Scheduled marker), so the two never disagree about what's pending.
const pending = pendingTransition(subscription?.current)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
return {
notice: undefined,
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
status: 'normal',
summary: [
@ -334,6 +349,22 @@ function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefin
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
}
/**
* A monthly-credits delta from a plan-change preview. NAS sends a bare dollar
* decimal ("-88"); credits are DOLLARS, so render it as signed dollars
* ("$88/mo"), never the raw number. Zero / absent null so the caller hides
* the line entirely.
*/
export function formatMonthlyCreditsDelta(delta?: null | string): null | string {
const amount = parseAmount(delta)
if (amount == null || amount === 0) {
return null
}
return `${amount < 0 ? '' : '+'}${formatMoney(Math.abs(amount))}/mo`
}
/**
* The current-plan card. It offers the in-app "View plans" / "Change plan" button
* ONLY when the account is plans-capable AND the grid has an actual UPGRADE to offer
@ -347,7 +378,8 @@ function derivePlanCard(
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
tiers: BillingPlanTierView[],
capable: boolean
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanCardView {
const current = subscription?.current
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
@ -359,17 +391,21 @@ function derivePlanCard(
const caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
: pending
? pending.kind === 'downgrade'
? `Changes to ${pending.tierName} on ${pending.when}.`
: `Cancels on ${pending.when}.`
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
// Actionable = a tile the user can act on. At ticket 09 only upgrades carry an
// `action` (downgrades are inert), so "has an action" ⟺ an upgrade exists. (The
// discriminated union means `'action' in tier` rather than `tier.action != null`.)
const hasActionableTier = tiers.some(tier => 'action' in tier)
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
// top-tier subscriber with neither still gets the portal-link fallback below.
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
if (capable && hasActionableTier) {
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, price, tierName }
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, pending, price, tierName }
}
return {
@ -379,16 +415,44 @@ function derivePlanCard(
label: 'Adjust plan ↗',
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
pending,
price,
tierName
}
}
// The change scheduled at period end (undoable via subscription.resume). NAS may
// carry a pending downgrade (`pending_downgrade_*`, with a target tier name) and/or a
// scheduled cancellation (`cancel_at_period_end` + `cancellation_effective_*`).
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
// tier, the stronger, more specific signal, and is what the grid marks.
function pendingTransition(
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
): PendingPlanTransition | undefined {
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
return {
kind: 'downgrade',
tierName: current.pending_downgrade_tier_name,
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at)
}
}
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
return {
kind: 'cancellation',
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at)
}
}
return undefined
}
/**
* The plans-grid catalog. Each card's state depends on its order relative to the
* current tier: current = inert marker; higher = "Choose ↗" opening the portal with
* the tier pre-selected; lower = disabled with a caption noting downgrades are
* moving in-app (ticket 11 wires the gateway pending-change flow). With no active
* the tier pre-selected; lower = an in-app "Downgrade" (chargeless, scheduled via the
* gateway). The already-scheduled downgrade target renders as an inert "Scheduled"
* marker; other lower tiers stay actionable (picking one reschedules). With no active
* subscription the lowest-order ($0 / free) tier stands in as the current plan, so
* there is no "subscribe to Free" upgrade and no downgrade state.
*
@ -401,7 +465,8 @@ function derivePlanCard(
function derivePlanTiers(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl: null | string,
capable: boolean
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
@ -428,6 +493,8 @@ function derivePlanTiers(
const currentTier = explicitCurrent ?? (current == null ? gridTiers[0] : undefined)
const currentOrder = currentTier?.tier_order
const manageBase = subscription.portal_url ?? fallbackPortalUrl
// Only a downgrade has a target tier to mark; a cancellation has none.
const pendingName = pending?.kind === 'downgrade' ? pending.tierName : null
return gridTiers.map((tier): BillingPlanTierView => {
const base: BillingPlanTierBase = {
@ -441,9 +508,18 @@ function derivePlanTiers(
return { ...base, state: 'current' }
}
// Downgrade = strictly below the current tier's order.
// A scheduled downgrade target is inert (matched by name — NAS sends no id for
// the pending target). Name is a safe key: SubscriptionTypes.name is @unique in
// NAS, so two tiers can't collide. Checked before the downgrade branch since the
// target IS a lower tier.
if (pendingName && tier.name === pendingName) {
return { ...base, state: 'scheduled' }
}
// Downgrade = strictly below the current tier's order → an in-app chargeless
// change (the PlanCard wires the confirm flow by tierId).
if (currentOrder != null && tier.tier_order < currentOrder) {
return { ...base, disabledCaption: 'Downgrades are moving in-app — coming soon.', state: 'downgrade' }
return { ...base, state: 'downgrade' }
}
return {

View file

@ -0,0 +1,171 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn()
}))
vi.mock('./api', () => ({ useBillingApi: () => apiMocks }))
import { useDowngradeFlow, useResumeFlow } from './use-subscription-change'
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
afterEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
})
describe('useDowngradeFlow', () => {
it('previews then schedules with the tier id, refetches, and calls onScheduled', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const onScheduled = vi.fn()
const { result } = renderHook(() => useDowngradeFlow({ onScheduled }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('t_free')
await act(async () => {
await result.current.confirm()
})
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('t_free')
expect(onScheduled).toHaveBeenCalledTimes(1)
expect(result.current.active).toBeNull()
})
it('records a preview refusal as the previewFailed phase and re-runs on retry', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('previewFailed'))
const phase = result.current.active?.phase
if (phase?.kind !== 'previewFailed') {
throw new Error('expected previewFailed phase')
}
expect(phase.refusal.kind).toBe('insufficient_scope')
act(() => result.current.retryPreview())
await waitFor(() => expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledTimes(2))
})
it('cancel clears the active change without scheduling', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
act(() => result.current.cancel())
expect(result.current.active).toBeNull()
expect(apiMocks.scheduleSubscriptionChange).not.toHaveBeenCalled()
})
it('exposes mutating only while the schedule RPC is in flight', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
expect(result.current.mutating).toBe(false)
act(() => {
void result.current.confirm()
})
await waitFor(() => expect(result.current.mutating).toBe(true))
act(() => settleSchedule({ data: { ok: true }, ok: true }))
await waitFor(() => expect(result.current.mutating).toBe(false))
})
it('fires a single schedule RPC when confirm is double-activated in the same tick', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
// Two synchronous activations before React commits busy='schedule'.
await act(async () => {
void result.current.confirm()
void result.current.confirm()
})
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
})
describe('useResumeFlow', () => {
it('resumes (undo) and clears the refusal on success', async () => {
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const { result } = renderHook(() => useResumeFlow(), { wrapper })
await act(async () => {
await result.current.resume()
})
expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1)
expect(result.current.refusal).toBeNull()
})
it('surfaces a resume refusal', async () => {
apiMocks.resumeSubscription.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
const { result } = renderHook(() => useResumeFlow(), { wrapper })
await act(async () => {
await result.current.resume()
})
expect(result.current.refusal?.kind).toBe('insufficient_scope')
})
})

View file

@ -0,0 +1,177 @@
import { useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import type { SubscriptionPreviewResponse } from './types'
export interface DowngradeTarget {
tierId: string
tierName: string
}
/**
* The state machine for one downgrade attempt. Modeled as a discriminated union
* rather than four independent nullables so impossible combinations (a preview AND a
* refusal, a "ready" with no quote) simply cannot be represented, and the panel reads
* exactly one `kind`.
*/
export type DowngradePhase =
| { kind: 'previewFailed'; refusal: BillingRefusal }
| { kind: 'previewing' }
| { kind: 'ready'; preview: SubscriptionPreviewResponse }
| { kind: 'scheduleFailed'; preview: SubscriptionPreviewResponse; refusal: BillingRefusal }
| { kind: 'scheduling'; preview: SubscriptionPreviewResponse }
export interface ActiveDowngrade {
phase: DowngradePhase
target: DowngradeTarget
}
function invalidateBilling(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }),
queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] })
])
}
/**
* The in-app downgrade flow: preview (chargeless quote) confirm schedule at
* period end. Typed refusals surface via the shared BillingRefusalInline (which
* drives the step-up for insufficient_scope, exactly like the auto-reload save);
* the caller retries by clicking the same button after verifying. On a scheduled
* success it refetches billing + subscription and calls `onScheduled`.
*
* The api comes from `useBillingApi`, which DEV fixtures override with a simulated
* implementation so this flow has no fixture/simulation awareness of its own.
*/
export function useDowngradeFlow({ onScheduled }: { onScheduled: () => void }) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [active, setActive] = useState<ActiveDowngrade | null>(null)
// Monotonic run id discards results from a superseded/cancelled attempt.
const runIdRef = useRef(0)
// Synchronous mutex: two clicks in the same tick both see active.busy === null
// (React hasn't committed the 'schedule' state yet), so guard on a ref too — no
// double schedule RPC. Cleared on every confirm() exit (below).
const schedulingRef = useRef(false)
const runPreview = async (target: DowngradeTarget, runId: number) => {
const result = await api.previewSubscriptionChange(target.tierId)
if (runIdRef.current !== runId) {
return
}
setActive({
phase: result.ok ? { kind: 'ready', preview: result.data } : { kind: 'previewFailed', refusal: result.refusal },
target
})
}
const begin = (target: DowngradeTarget) => {
const runId = runIdRef.current + 1
runIdRef.current = runId
setActive({ phase: { kind: 'previewing' }, target })
void runPreview(target, runId)
}
const retryPreview = () => {
if (active) {
begin(active.target)
}
}
const confirm = async () => {
// Only a quoted state (ready, or a failed schedule being retried) can commit.
if (!active || schedulingRef.current || (active.phase.kind !== 'ready' && active.phase.kind !== 'scheduleFailed')) {
return
}
schedulingRef.current = true
const { target } = active
const { preview } = active.phase
const runId = runIdRef.current + 1
runIdRef.current = runId
setActive({ phase: { kind: 'scheduling', preview }, target })
const result = await api.scheduleSubscriptionChange(target.tierId)
schedulingRef.current = false
if (runIdRef.current !== runId) {
return
}
if (!result.ok) {
// A refusal (e.g. insufficient_scope → step-up) leaves the panel open in
// scheduleFailed, so the same button becomes a manual "Try again" AFTER the
// user elevates. We deliberately do NOT auto-replay the mutation on step-up
// success — this matches the auto-reload save's manual-retry pattern.
setActive({ phase: { kind: 'scheduleFailed', preview, refusal: result.refusal }, target })
return
}
await invalidateBilling(queryClient)
setActive(null)
onScheduled()
}
const cancel = () => {
runIdRef.current += 1
setActive(null)
}
// True only while the mutating RPC (schedule) is in flight — used to lock out
// every other Downgrade tile + Back while one change is committing (the server
// also 409s overlapping mutations per-org, so this is UI honesty, not the only
// defense).
const mutating = active?.phase.kind === 'scheduling'
return { active, begin, cancel, confirm, mutating, retryPreview }
}
/**
* The undo for a scheduled downgrade / cancellation: a chargeless
* `subscription.resume` (no confirm step) that refetches on success. A refusal
* (e.g. insufficient_scope step-up) surfaces via `refusal`. The api (real or the
* DEV-fixture simulation) comes from `useBillingApi`.
*/
export function useResumeFlow() {
const api = useBillingApi()
const queryClient = useQueryClient()
const [busy, setBusy] = useState(false)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const runningRef = useRef(false)
const resume = async () => {
if (runningRef.current) {
return
}
runningRef.current = true
setBusy(true)
setRefusal(null)
const result = await api.resumeSubscription()
if (!result.ok) {
// Refusal → unlock immediately so the user can retry / step up.
runningRef.current = false
setBusy(false)
setRefusal(result.refusal)
return
}
// Success → hold the lock (Undo stays disabled) THROUGH the refetch, so the
// button never re-enables against the stale, still-pending card.
await invalidateBilling(queryClient)
runningRef.current = false
setBusy(false)
}
return { busy, refusal, resume }
}