mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
9baad4e0aa
commit
60ec6a3b8e
19 changed files with 1786 additions and 643 deletions
48
apps/desktop/src/app/settings/billing/account-row-value.tsx
Normal file
48
apps/desktop/src/app/settings/billing/account-row-value.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
290
apps/desktop/src/app/settings/billing/auto-reload-row.tsx
Normal file
290
apps/desktop/src/app/settings/billing/auto-reload-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
apps/desktop/src/app/settings/billing/billing-amounts.ts
Normal file
123
apps/desktop/src/app/settings/billing/billing-amounts.ts
Normal 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)
|
||||
}
|
||||
57
apps/desktop/src/app/settings/billing/current-plan-card.tsx
Normal file
57
apps/desktop/src/app/settings/billing/current-plan-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
70
apps/desktop/src/app/settings/billing/inline-feedback.tsx
Normal file
70
apps/desktop/src/app/settings/billing/inline-feedback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
apps/desktop/src/app/settings/billing/open-external.ts
Normal file
9
apps/desktop/src/app/settings/billing/open-external.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
57
apps/desktop/src/app/settings/billing/simulated-api.test.ts
Normal file
57
apps/desktop/src/app/settings/billing/simulated-api.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
93
apps/desktop/src/app/settings/billing/simulated-api.ts
Normal file
93
apps/desktop/src/app/settings/billing/simulated-api.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
177
apps/desktop/src/app/settings/billing/use-subscription-change.ts
Normal file
177
apps/desktop/src/app/settings/billing/use-subscription-change.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue