From 60ec6a3b8ee147406ecb1ab1bab9cec673995ad7 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:16:06 +0530 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20native=20in-app=20downgrade=20?= =?UTF-8?q?=E2=80=94=20chargeless=20preview=20=E2=86=92=20schedule=20?= =?UTF-8?q?=E2=86=92=20undo=20(#68761)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 . 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 on ." 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 ." 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. --- .../settings/billing/account-row-value.tsx | 48 ++ .../src/app/settings/billing/api.test.ts | 42 ++ apps/desktop/src/app/settings/billing/api.ts | 33 +- .../app/settings/billing/auto-reload-row.tsx | 290 +++++++++ .../app/settings/billing/billing-amounts.ts | 123 ++++ .../settings/billing/current-plan-card.tsx | 57 ++ .../src/app/settings/billing/dev-fixtures.ts | 42 ++ .../src/app/settings/billing/index.test.tsx | 218 ++++++- .../src/app/settings/billing/index.tsx | 603 ++---------------- .../app/settings/billing/inline-feedback.tsx | 70 ++ .../src/app/settings/billing/open-external.ts | 9 + .../src/app/settings/billing/plans-view.tsx | 149 ++++- .../settings/billing/simulated-api.test.ts | 57 ++ .../src/app/settings/billing/simulated-api.ts | 93 +++ .../desktop/src/app/settings/billing/types.ts | 2 + .../billing/use-billing-state.test.ts | 127 +++- .../app/settings/billing/use-billing-state.ts | 118 +++- .../billing/use-subscription-change.test.tsx | 171 +++++ .../billing/use-subscription-change.ts | 177 +++++ 19 files changed, 1786 insertions(+), 643 deletions(-) create mode 100644 apps/desktop/src/app/settings/billing/account-row-value.tsx create mode 100644 apps/desktop/src/app/settings/billing/auto-reload-row.tsx create mode 100644 apps/desktop/src/app/settings/billing/billing-amounts.ts create mode 100644 apps/desktop/src/app/settings/billing/current-plan-card.tsx create mode 100644 apps/desktop/src/app/settings/billing/inline-feedback.tsx create mode 100644 apps/desktop/src/app/settings/billing/open-external.ts create mode 100644 apps/desktop/src/app/settings/billing/simulated-api.test.ts create mode 100644 apps/desktop/src/app/settings/billing/simulated-api.ts create mode 100644 apps/desktop/src/app/settings/billing/use-subscription-change.test.tsx create mode 100644 apps/desktop/src/app/settings/billing/use-subscription-change.ts diff --git a/apps/desktop/src/app/settings/billing/account-row-value.tsx b/apps/desktop/src/app/settings/billing/account-row-value.tsx new file mode 100644 index 000000000000..4c54e4cbac7b --- /dev/null +++ b/apps/desktop/src/app/settings/billing/account-row-value.tsx @@ -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 ( +
+ {row.value && ( + + {row.value} + + )} + {row.pill && {row.pill.label}} + {row.secondaryPill && {row.secondaryPill}} + {row.chips?.map(chip => ( + + ))} + {action && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/api.test.ts b/apps/desktop/src/app/settings/billing/api.test.ts index 02f359c35a58..aa5f9c384952 100644 --- a/apps/desktop/src/app/settings/billing/api.test.ts +++ b/apps/desktop/src/app/settings/billing/api.test.ts @@ -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 }) diff --git a/apps/desktop/src/app/settings/billing/api.ts b/apps/desktop/src/app/settings/billing/api.ts index 6177db8da1b6..8632ae1b2861 100644 --- a/apps/desktop/src/app/settings/billing/api.ts +++ b/apps/desktop/src/app/settings/billing/api.ts @@ -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> fetchBillingState: () => Promise> fetchSubscriptionState: () => Promise> + /** Chargeless quote for a plan change (POST /subscription/preview). */ + previewSubscriptionChange: (tierId: string) => Promise> + /** Clear a scheduled downgrade / cancellation — the undo (DELETE pending-change). */ + resumeSubscription: () => Promise> + /** Schedule a chargeless downgrade at period end (PUT pending-change). */ + scheduleSubscriptionChange: (tierId: string) => Promise> stepUp: (sessionId?: string) => Promise> updateAutoReload: (input: UpdateAutoReloadInput) => Promise> } @@ -149,6 +156,15 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing callBilling(requestGateway, 'billing.charge_status', { charge_id: chargeId }), fetchBillingState: () => callBilling(requestGateway, 'billing.state'), fetchSubscriptionState: () => callBilling(requestGateway, 'subscription.state'), + previewSubscriptionChange: tierId => + callBilling(requestGateway, 'subscription.preview', { + subscription_type_id: tierId + }), + resumeSubscription: () => callBilling(requestGateway, 'subscription.resume', {}), + scheduleSubscriptionChange: tierId => + callBilling(requestGateway, 'subscription.change', { + subscription_type_id: tierId + }), stepUp: sessionId => callBilling(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(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 } diff --git a/apps/desktop/src/app/settings/billing/auto-reload-row.tsx b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx new file mode 100644 index 000000000000..5b5b94017ef6 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx @@ -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 + 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) + const [refusal, setRefusal] = useState(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 ( + } + below={ + <> + {row.caption ? ( +
+ {row.caption} +
+ ) : null} + + {message && {message.text}} + + } + 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 ( +
+
+
+
+ {row.title} +
+
+ {row.description} +
+
+ {/* EDIT layer — always in layout (reserves exact height); hidden until editing. */} +
+
+ + +
+ {/* Pre-allocated error line — occupies height whether or not shown. */} +
+ {showErrors && validation.error ? validation.error : ''} +
+ {confirmDisable ? ( +
+ Turn off auto-refill? + + +
+ ) : ( + + )} + {/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */} + +
+ {/* VIEW layer — success feedback overlaid in the same cell when not editing. */} + {!editing && message && ( +
+ {message.text} +
+ )} +
+
+ {/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */} +
+ {row.pill && {row.pill.label}} + {editing ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) +} + +// 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 ( +
+ {children} +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/billing-amounts.ts b/apps/desktop/src/app/settings/billing/billing-amounts.ts new file mode 100644 index 000000000000..0b0e5517b92e --- /dev/null +++ b/apps/desktop/src/app/settings/billing/billing-amounts.ts @@ -0,0 +1,123 @@ +import type { BillingStateResponse } from './types' +import { EMPTY_BILLING_VALUE } from './use-billing-state' + +export function clampAmount(raw: string, billing: Pick): 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): 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 +): { 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 +): { 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) +} diff --git a/apps/desktop/src/app/settings/billing/current-plan-card.tsx b/apps/desktop/src/app/settings/billing/current-plan-card.tsx new file mode 100644 index 000000000000..c9118e574b61 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/current-plan-card.tsx @@ -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 ( +
+
+
+ +
+
+ + {plan.tierName} + + {plan.price && ( + + {plan.price}/mo + + )} +
+
+ {plan.caption} +
+
+
+
+ {plan.action && ( + + )} + {/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */} + {plan.pending && ( + + )} + {plan.link && ( + + )} +
+
+ +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/dev-fixtures.ts b/apps/desktop/src/app/settings/billing/dev-fixtures.ts index d5ed680586f2..91e3f9f953d5 100644 --- a/apps/desktop/src/app/settings/billing/dev-fixtures.ts +++ b/apps/desktop/src/app/settings/billing/dev-fixtures.ts @@ -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 => ({ data, ok: true }) const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ @@ -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, diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index ad12438cbbcb..12d408d8cc5f 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -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 () => { diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 814585c49d8e..2d234d74c88d 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -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 (
@@ -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 ( -
- {row.value && ( - - {row.value} - - )} - {row.pill && {row.pill.label}} - {row.secondaryPill && {row.secondaryPill}} - {row.chips?.map(chip => ( - - ))} - {action && ( - - )} -
- ) -} - 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 @@ -159,306 +112,6 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil ) } -function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) { - return ( -
-
-
- -
-
- - {plan.tierName} - - {plan.price && ( - - {plan.price}/mo - - )} -
-
- {plan.caption} -
-
-
-
- {plan.action && ( - - )} - {plan.link && ( - - )} -
-
-
- ) -} - -function AutoReloadRow({ - autoReload, - bounds, - row -}: { - autoReload: BillingAutoReload - bounds: Pick - 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) - const [refusal, setRefusal] = useState(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 ( - } - below={ - <> - {row.caption ? ( -
- {row.caption} -
- ) : null} - - {message && {message.text}} - - } - 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 ( -
-
-
-
- {row.title} -
-
- {row.description} -
-
- {/* EDIT layer — always in layout (reserves exact height); hidden until editing. */} -
-
- - -
- {/* Pre-allocated error line — occupies height whether or not shown. */} -
- {showErrors && validation.error ? validation.error : ''} -
- {confirmDisable ? ( -
- Turn off auto-refill? - - -
- ) : ( - - )} - {/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */} - -
- {/* VIEW layer — success feedback overlaid in the same cell when not editing. */} - {!editing && message && ( -
- {message.text} -
- )} -
-
- {/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */} -
- {row.pill && {row.pill.label}} - {editing ? ( - <> - - - - ) : ( - - )} -
-
-
- ) -} - 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 ( -
- - {resolved.title}: {resolved.message} - - {resolved.action.type === 'step_up' && } - {portalUrl && ( - - )} -
- ) -} - -function StepUpInlineAction({ flow }: { flow: ReturnType }) { - if (flow.verification) { - return ( - - {flow.verification.code} - - - ) - } - - if (flow.message) { - return ( - - - {flow.message.title}: {flow.message.text} - - - - ) - } - - if (flow.phase === 'waiting') { - return Waiting for verification link… - } - - return ( - - ) -} - -function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) { - return ( -
- {children} -
- ) -} - 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('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('live') + const queryClient = useQueryClient() - return + // 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 ( + + + + ) } export function BillingSettings() { @@ -991,129 +587,8 @@ export function BillingSettings() { return } -function clampAmount(raw: string, billing: Pick): 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): 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 -): { 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 -): { 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) -} diff --git a/apps/desktop/src/app/settings/billing/inline-feedback.tsx b/apps/desktop/src/app/settings/billing/inline-feedback.tsx new file mode 100644 index 000000000000..dcc3e27993a9 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/inline-feedback.tsx @@ -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 }) { + if (flow.verification) { + return ( + + {flow.verification.code} + + + ) + } + + if (flow.message) { + return ( + + + {flow.message.title}: {flow.message.text} + + + + ) + } + + if (flow.phase === 'waiting') { + return Waiting for verification link… + } + + return ( + + ) +} + +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 ( +
+ + {resolved.title}: {resolved.message} + + {resolved.action.type === 'step_up' && } + {portalUrl && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/open-external.ts b/apps/desktop/src/app/settings/billing/open-external.ts new file mode 100644 index 000000000000..dfda48475e64 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/open-external.ts @@ -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) + } +} diff --git a/apps/desktop/src/app/settings/billing/plans-view.tsx b/apps/desktop/src/app/settings/billing/plans-view.tsx index 30ca37accf9c..9911ca720859 100644 --- a/apps/desktop/src/app/settings/billing/plans-view.tsx +++ b/apps/desktop/src/app/settings/billing/plans-view.tsx @@ -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 + +// 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(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 ( +
+ {message &&
{message}
} + + + +
+ {phase.kind === 'previewFailed' ? ( + + ) : canConfirm ? ( + + ) : null} + +
+
+ ) +} + +function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) { const isCurrent = tier.state === 'current' + const confirming = flow.active?.target.tierId === tier.tierId + const cardRef = useRef(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 (
@@ -39,6 +152,8 @@ function PlanCard({ tier }: { tier: BillingPlanTierView }) {
{isCurrent && Current plan} + {tier.state === 'scheduled' && Scheduled} + {tier.state === 'upgrade' && ( )} - {tier.state === 'downgrade' && ( -
- - - {tier.disabledCaption} - -
- )} + ))}
) } 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 (