Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -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>
)
}
@@ -0,0 +1,214 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BillingChargeResponse, BillingStateResponse } from './types'
const requestGatewayMock = vi.hoisted(() => vi.fn())
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway: requestGatewayMock })
}))
import { createBillingApi, useBillingApi } from './api'
describe('createBillingApi', () => {
beforeEach(() => {
requestGatewayMock.mockReset()
vi.restoreAllMocks()
})
it('passes successful RPC results through as data', async () => {
const state = {
auto_reload: null,
balance_display: '$10.00',
balance_usd: '10',
can_charge: true,
card: null,
charge_presets: ['10'],
charge_presets_display: ['$10'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '100',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Nous',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER'
} satisfies BillingStateResponse
requestGatewayMock.mockResolvedValueOnce(state)
const { result } = renderHook(() => useBillingApi())
const response = await result.current.fetchBillingState()
expect(response).toEqual({ data: state, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('billing.state', {})
})
it('normalizes object-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: {
kind: 'no_payment_method',
message: 'No saved card.',
portal_url: 'https://portal.nousresearch.com/billing',
retry_after: 30
},
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.chargeStatus('ch_123')
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 30
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.charge_status', { charge_id: 'ch_123' })
})
it('normalizes current string-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
ok: false,
payload: { remainingUsd: '4.50' },
portal_url: 'https://portal.nousresearch.com/billing'
})
const api = createBillingApi(requestGatewayMock)
const response = await api.updateAutoReload({ enabled: true, reload_to_usd: '100', threshold_usd: '25' })
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' },
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.auto_reload', {
enabled: true,
threshold: '25',
top_up_amount: '100'
})
})
it('maps thrown gateway failures to transport refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('connection closed'))
const api = createBillingApi(requestGatewayMock)
const response = await api.fetchSubscriptionState()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed',
raw: expect.any(Error)
}
})
})
it('maps thrown timeout failures to timeout refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('request timed out after 5000ms'))
const api = createBillingApi(requestGatewayMock)
const response = await api.stepUp()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'timeout',
message: 'request timed out after 5000ms',
raw: expect.any(Error)
}
})
})
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 })
const api = createBillingApi(requestGatewayMock)
await api.stepUp('session-123')
expect(requestGatewayMock).toHaveBeenCalledWith('billing.step_up', { session_id: 'session-123' })
})
it('sends a minted charge idempotency key and reuses it on explicit retry', async () => {
vi.spyOn(crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
const submitted = {
charge_id: 'ch_123',
idempotency_key: '11111111-1111-4111-8111-111111111111',
ok: true
} satisfies BillingChargeResponse
requestGatewayMock.mockResolvedValue(submitted)
const api = createBillingApi(requestGatewayMock)
const first = await api.charge('25')
const second = await api.charge('25', first.idempotencyKey)
expect(first).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(second).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(crypto.randomUUID).toHaveBeenCalledTimes(1)
expect(requestGatewayMock).toHaveBeenNthCalledWith(1, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
expect(requestGatewayMock).toHaveBeenNthCalledWith(2, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
})
})
@@ -0,0 +1,193 @@
import { createContext, useContext, useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type {
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse
} from './types'
export type BillingErrorKind = BillingRefusalCode
export interface BillingRefusal {
actor?: string
code?: string
kind: BillingErrorKind | 'timeout' | 'transport'
message: string
payload?: BillingErrorPayload
portalUrl?: string
raw?: unknown
recovery?: string
retryAfter?: number
}
export type BillingResult<T> = { data: T; ok: true } | { ok: false; refusal: BillingRefusal }
export type BillingChargeResult = BillingResult<BillingChargeResponse> & { idempotencyKey: string }
export interface UpdateAutoReloadInput {
enabled: boolean
reload_to_usd?: string
threshold_usd?: string
}
export type BillingRequestGateway = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
export interface BillingApi {
charge: (amountUsd: string, idempotencyKey?: string) => Promise<BillingChargeResult>
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>>
}
interface RefusalRecord {
actor?: unknown
code?: unknown
error?: unknown
kind?: unknown
message?: unknown
payload?: unknown
portal_url?: unknown
recovery?: unknown
retry_after?: unknown
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
const asOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined
const asOptionalNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined)
const asPayload = (value: unknown): BillingErrorPayload | undefined =>
isRecord(value) ? (value as BillingErrorPayload) : undefined
const getMessage = (value: unknown): string => {
if (value instanceof Error && value.message) {
return value.message
}
if (typeof value === 'string' && value.length > 0) {
return value
}
return String(value || 'Billing request failed.')
}
const normalizeRefusal = (raw: Record<string, unknown>): BillingRefusal => {
const rawError = raw.error
const error = isRecord(rawError) ? (rawError as RefusalRecord) : undefined
const kind = asOptionalString(error?.kind) ?? asOptionalString(error?.error) ?? asOptionalString(rawError) ?? 'error'
const message = asOptionalString(error?.message) ?? asOptionalString(raw.message) ?? kind
return {
actor: asOptionalString(error?.actor) ?? asOptionalString(raw.actor),
code: asOptionalString(error?.code) ?? asOptionalString(raw.code),
kind,
message,
payload: asPayload(error?.payload) ?? asPayload(raw.payload),
portalUrl: asOptionalString(error?.portal_url) ?? asOptionalString(raw.portal_url),
raw,
recovery: asOptionalString(error?.recovery) ?? asOptionalString(raw.recovery),
retryAfter: asOptionalNumber(error?.retry_after) ?? asOptionalNumber(raw.retry_after)
}
}
const normalizeThrown = (error: unknown): BillingRefusal => {
const message = getMessage(error)
const name = error instanceof Error ? error.name : ''
return {
kind: name === 'TimeoutError' || /timed?\s*out|timeout/i.test(message) ? 'timeout' : 'transport',
message,
raw: error
}
}
const normalizeRpcResult = <T>(response: T): BillingResult<T> => {
if (isRecord(response) && response.ok === false) {
return { ok: false, refusal: normalizeRefusal(response) }
}
return { data: response, ok: true }
}
const callBilling = async <T>(
requestGateway: BillingRequestGateway,
method: string,
params: Record<string, unknown> = {}
): Promise<BillingResult<T>> => {
try {
return normalizeRpcResult(await requestGateway<T>(method, params))
} catch (error) {
return { ok: false, refusal: normalizeThrown(error) }
}
}
export const createBillingApi = (requestGateway: BillingRequestGateway): BillingApi => ({
charge: async (amountUsd, idempotencyKey = crypto.randomUUID()) => {
const result = await callBilling<BillingChargeResponse>(requestGateway, 'billing.charge', {
amount_usd: amountUsd,
idempotency_key: idempotencyKey
})
return { ...result, idempotencyKey }
},
chargeStatus: chargeId =>
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 } : {})
}),
updateAutoReload: input =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.auto_reload', {
enabled: input.enabled,
...(input.threshold_usd !== undefined ? { threshold: input.threshold_usd } : {}),
...(input.reload_to_usd !== undefined ? { top_up_amount: input.reload_to_usd } : {})
})
})
// 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)
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
}
@@ -0,0 +1,293 @@
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>
)
}
@@ -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)
}
@@ -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>
)
}
@@ -0,0 +1,469 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from './types'
const current = (
overrides: Partial<NonNullable<SubscriptionStateResponse['current']>> = {}
): NonNullable<SubscriptionStateResponse['current']> => ({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
credits_remaining: '120',
cycle_ends_at: '2026-07-11T08:14:55.000Z',
monthly_credits: '220',
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null,
tier_id: 'ultra',
tier_name: 'Ultra',
...overrides
})
export const todayBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$10',
reload_to_usd: '10',
threshold_display: '$5',
threshold_usd: '5'
},
balance_display: '$996.47',
balance_usd: '996.47',
can_charge: false,
card: {
brand: 'visa',
last4: '3206',
masked: 'visa ....3206'
},
charge_presets: ['100', '250', '500'],
charge_presets_display: ['$100', '$250', '$500'],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: true,
limit_display: '$100',
limit_usd: '100',
spent_display: '$10',
spent_this_month_usd: '10'
},
ok: true,
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_name: 'Ultra',
renews_at: '2026-07-11T08:14:55.000Z',
renews_display: 'Jul 11',
status: 'active',
subscription_remaining_display: '$120',
topup_remaining_display: '$876.47',
total_spendable_display: '$996.47'
}
} satisfies BillingStateResponse
export const todaySubscriptionState = {
can_change_plan: true,
context: 'team',
current: current(),
is_admin: true,
logged_in: true,
ok: true,
org_id: 'sid-5',
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
tiers: [
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 3
}
],
usage: todayBillingState.usage
} satisfies SubscriptionStateResponse
export const postTrainBillingState = {
...todayBillingState,
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
org_name: 'Acme Research',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
export const postTrainSubscriptionState = {
...todaySubscriptionState,
current: current({
credits_remaining: '40',
cycle_ends_at: '2026-07-31T00:00:00Z',
monthly_credits: '100',
tier_id: 'pro',
tier_name: 'Pro'
}),
org_id: 'org_123',
org_name: 'Acme Research',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '100',
name: 'Pro',
tier_id: 'pro',
tier_order: 2
}
],
usage: postTrainBillingState.usage
} satisfies SubscriptionStateResponse
export const loggedOutBillingState = {
...todayBillingState,
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: null,
charge_presets: [],
charge_presets_display: [],
logged_in: false,
monthly_cap: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
usage: undefined
} satisfies BillingStateResponse
export const loggedOutSubscriptionState = {
...todaySubscriptionState,
can_change_plan: false,
current: null,
is_admin: false,
logged_in: false,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: [],
usage: undefined
} satisfies SubscriptionStateResponse
// Full four-tier personal catalog. tier_ids are cuid-like (Prisma) on purpose:
// tier art keys off the lowercase NAME, never the id (ids differ per env). Dollar
// credits are 0.1 / 22 / 110 / 220 to exercise the "$X credits/mo" formatting.
const personalTierCatalog: SubscriptionTierOption[] = [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier000free0000personal',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'cltier111plus1111personal',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 'cltier222super222personal',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: false,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'cltier333ultra333personal',
tier_order: 3
}
]
const catalogWithCurrent = (currentTierId: null | string): SubscriptionTierOption[] =>
personalTierCatalog.map(tier => ({ ...tier, is_current: tier.tier_id === currentTierId }))
// Logged-in personal org, no subscription: exercises the "View plans" plan card
// and the full plans grid where every tier is an upgrade.
export const freePersonalBillingState = {
...postTrainBillingState,
balance_display: '$12.00',
balance_usd: '12.00',
org_name: 'Personal',
usage: {
available: true,
has_topup: true,
plan_name: 'Free',
renews_at: null,
renews_display: null,
status: 'active',
subscription_remaining_display: '$0',
topup_remaining_display: '$12.00',
total_spendable_display: '$12.00'
}
} satisfies BillingStateResponse
export const freePersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
org_id: 'org_personal_free',
org_name: 'Personal',
tiers: catalogWithCurrent(null),
usage: freePersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus: exercises the "Change plan" plan card, the current
// marker, upgrades (Super/Ultra), and the disabled downgrade (Free).
export const subscriberPersonalBillingState = {
...postTrainBillingState,
org_name: 'Personal',
usage: {
...postTrainBillingState.usage,
plan_name: 'Plus'
}
} satisfies BillingStateResponse
export const subscriberPersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
}),
org_id: 'org_personal_plus',
org_name: 'Personal',
tiers: catalogWithCurrent('cltier111plus1111personal'),
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> => ({
data,
ok: true
})
function withUsage(
name: string,
{
autoReload = postTrainBillingState.auto_reload,
canCharge = true,
card = postTrainBillingState.card,
cliBillingEnabled = true,
monthlyCapSpent = '89',
remaining,
subscriptionCurrent = current({ credits_remaining: remaining, monthly_credits: '220' })
}: {
autoReload?: BillingStateResponse['auto_reload']
canCharge?: boolean
card?: BillingStateResponse['card']
cliBillingEnabled?: boolean
monthlyCapSpent?: string
remaining: string
subscriptionCurrent?: SubscriptionStateResponse['current']
}
) {
const billing = {
...postTrainBillingState,
auto_reload: autoReload,
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: canCharge,
card,
cli_billing_enabled: cliBillingEnabled,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$100',
limit_usd: '100',
spent_display: `$${monthlyCapSpent}`,
spent_this_month_usd: monthlyCapSpent
},
org_name: `${name} Fixture`,
usage: {
...postTrainBillingState.usage,
plan_name: 'Ultra',
subscription_remaining_display: `$${remaining}`,
total_spendable_display: '$142.50'
}
} satisfies BillingStateResponse
const subscription = {
...todaySubscriptionState,
current: subscriptionCurrent,
org_name: `${name} Fixture`,
usage: billing.usage
} satisfies SubscriptionStateResponse
return { billing: okBilling(billing), subscription: okSubscription(subscription) }
}
export const billingDevFixtures = {
healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }),
'free-personal': {
billing: okBilling(freePersonalBillingState),
subscription: okSubscription(freePersonalSubscriptionState)
},
'subscriber-personal': {
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,
card: { kind: 'distinct', payment_method_id: 'pm_divergent_1', brand: 'mastercard', last4: '4444' },
enabled: true
},
remaining: '132'
}),
low: withUsage('Low', { remaining: '19.8' }),
boundary: withUsage('Boundary', { remaining: '22' }),
'empty-overdrawn': withUsage('Empty Overdrawn', { remaining: '-0.79' }),
'cap-near': withUsage('Cap Near', { monthlyCapSpent: '92', remaining: '132' }),
'cap-hit': withUsage('Cap Hit', { monthlyCapSpent: '100', remaining: '132' }),
'no-card': withUsage('No Card', { card: null, remaining: '132' }),
'no-subscription': withUsage('No Subscription', { remaining: '132', subscriptionCurrent: null }),
'logged-out': {
billing: okBilling(loggedOutBillingState),
subscription: okSubscription(loggedOutSubscriptionState)
},
refusal: {
billing: {
ok: false,
refusal: {
kind: 'temporarily_unavailable',
message: 'Billing is temporarily unavailable.',
retryAfter: 90
}
},
subscription: okSubscription(todaySubscriptionState)
},
'billing-off': {
billing: okBilling(todayBillingState),
subscription: okSubscription(todaySubscriptionState)
}
} satisfies Record<
string,
{
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
>
export type BillingDevFixtureName = keyof typeof billingDevFixtures
@@ -0,0 +1,84 @@
import type { KnownBillingRefusalCode } from '@hermes/shared/billing'
import { describe, expect, it } from 'vitest'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
const expectedActions: Record<
KnownBillingRefusalCode | 'timeout' | 'transport',
'none' | 'portal' | 'retry' | 'step_up'
> = {
auto_top_up_disabled_failures: 'none',
cli_billing_disabled: 'portal',
consent_required: 'portal',
endpoint_unavailable: 'retry',
idempotency_conflict: 'none',
idempotency_key_required: 'none',
insufficient_scope: 'step_up',
internal_error: 'none',
invalid_charge_id: 'none',
invalid_request: 'none',
monthly_cap_exceeded: 'portal',
network_error: 'none',
no_payment_method: 'portal',
org_access_denied: 'none',
preview_rejected: 'none',
rate_limited: 'retry',
remote_spending_disabled: 'portal',
remote_spending_revoked: 'portal',
role_required: 'portal',
session_revoked: 'portal',
stripe_unavailable: 'retry',
temporarily_unavailable: 'retry',
timeout: 'retry',
transport: 'retry',
upgrade_cap_exceeded: 'none',
validation_failed: 'none'
}
describe('resolveRefusal', () => {
it('maps every known refusal kind to copy and the expected action', () => {
for (const [kind, actionType] of Object.entries(expectedActions)) {
const resolved = resolveRefusal({
kind: kind as BillingRefusal['kind'],
message: 'Server message.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 90
})
expect(resolved.title, kind).not.toHaveLength(0)
expect(resolved.message, kind).not.toHaveLength(0)
expect(resolved.action.type, kind).toBe(actionType)
}
})
it('includes monthly cap headroom when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' }
})
expect(resolved.message).toContain('$4.50 headroom left')
})
it('includes Stripe retry timing when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'stripe_unavailable',
message: 'Stripe is unavailable.',
retryAfter: 120
})
expect(resolved.message).toContain('try again in ~2 min')
})
it('falls back sanely for unknown refusal kinds', () => {
const resolved = resolveRefusal({ kind: 'new_billing_code', message: 'Something changed upstream.' })
expect(resolved).toEqual({
action: { type: 'none' },
message: 'Something changed upstream.',
title: 'Billing request failed'
})
})
})
@@ -0,0 +1,163 @@
import type { BillingRefusal } from './api'
export interface BillingRefusalPresentation {
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
message: string
title: string
}
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
const retryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
}
const stripeRetryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `Stripe is having trouble — try again shortly${mins}`
}
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
switch (refusal.kind) {
case 'consent_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Confirm this card for terminal charges in the portal',
title: 'Card confirmation needed'
}
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
title: 'Remote Spending needs approval'
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin stopped remote spending for this terminal.'
: 'You stopped remote spending for this terminal.'
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Remote spending was stopped'
}
}
case 'session_revoked':
return {
action: portalAction(refusal.portalUrl),
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
title: 'Session logged out'
}
case 'cli_billing_disabled':
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message:
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
title: 'Remote spending is off'
}
case 'role_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
title: 'Admin role required'
}
case 'idempotency_conflict':
return {
action: { type: 'none' },
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
title: 'Start a fresh top-up'
}
case 'no_payment_method':
return {
action: portalAction(refusal.portalUrl),
message:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card).",
title: 'No saved card'
}
case 'org_access_denied':
return {
action: { type: 'none' },
message: "This token isn't bound to an org you can manage",
title: 'Org access denied'
}
case 'monthly_cap_exceeded': {
const remaining = refusal.payload?.remainingUsd
return {
action: portalAction(refusal.portalUrl),
message:
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.',
title: 'Monthly spend cap reached'
}
}
case 'rate_limited':
case 'temporarily_unavailable':
return {
action: { type: 'retry' },
message: retryMessage(refusal),
title: 'Too many charges right now'
}
case 'stripe_unavailable':
return {
action: { type: 'retry' },
message: stripeRetryMessage(refusal),
title: 'Stripe is having trouble'
}
case 'upgrade_cap_exceeded':
return {
action: { type: 'none' },
message: 'Daily plan-change limit reached — try again tomorrow',
title: 'Daily plan-change limit reached'
}
case 'endpoint_unavailable':
return {
action: { type: 'retry' },
message:
refusal.message ||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
title: 'Billing endpoint unavailable'
}
case 'timeout':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request timed out.',
title: 'Billing request timed out'
}
case 'transport':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request failed before reaching the gateway.',
title: 'Billing connection failed'
}
default:
return {
action: { type: 'none' },
message: refusal.message || 'Billing request failed.',
title: 'Billing request failed'
}
}
}
@@ -0,0 +1,35 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
export {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './dev-fixtures'
export const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
export const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
export const endpointUnavailableBilling = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Billing endpoint returned a non-JSON response.'
}
} satisfies BillingResult<BillingStateResponse>
export const endpointUnavailableSubscription = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Subscription endpoint is not available.'
}
} satisfies BillingResult<SubscriptionStateResponse>
@@ -0,0 +1,694 @@
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'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { formatMoney } from './billing-amounts'
import {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { BillingSettings } from './index'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn(),
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
})
}))
function renderBilling(initialEntries: string[] = ['/settings?tab=billing']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
)
return client
}
beforeEach(() => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(todayBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(todaySubscriptionState))
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: apiMocks.openExternal
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('BillingSettings', () => {
it('renders the deployed-today payload with buy controls hidden and usage rows visible', async () => {
renderBilling()
expect(await screen.findByText('$996.47')).toBeTruthy()
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByText('$120 of $220 left')).toBeTruthy()
expect(screen.getByText('$876.47')).toBeTruthy()
expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true)
expect(screen.getByText('Default ceiling')).toBeTruthy()
})
it('renders the post-train payload with enabled buy controls and card provenance', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect(await screen.findByText('$142.50')).toBeTruthy()
expect(screen.getByText('Visa •••• 4242 - subscription card')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' })).toBeTruthy()
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false)
})
it('disables buy controls when no card is on file', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
// No card → the payment row collapses to a single "Add payment method" link.
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /^Buy$/ }))
expect(apiMocks.charge).not.toHaveBeenCalled()
})
it('saves enabled auto-refill edits and refreshes billing state', async () => {
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: true,
reload_to_usd: '20',
threshold_usd: '15'
})
)
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }))
expect(await screen.findByText('Auto-refill updated.')).toBeTruthy()
})
it('rejects auto-refill amounts outside the billing bounds', async () => {
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '7.50' }
})
expect(screen.getByText(`Threshold: minimum is ${formatMoney(10)}.`)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('renders the enabled auto-refill row without crashing when the card is null', async () => {
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } })
)
renderBilling()
expect(await screen.findByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.click(screen.getByRole('button', { name: 'Disable' }))
expect(screen.getByText('Turn off auto-refill?')).toBeTruthy()
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
// The gateway requires threshold + top_up_amount even to disable, so the current
// amounts ride along (todayBillingState: threshold $5, reload-to $10).
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: false,
reload_to_usd: '10',
threshold_usd: '5'
})
)
})
it('opens auto-refill edit without a validation error even when the saved config is below the minimum', async () => {
// todayBillingState: threshold $5 with min_usd $10 — invalid, but opening
// Manage must stay silent until the user edits or attempts to save (spec §9).
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
expect(screen.queryByText(`Threshold: minimum is ${formatMoney(10)}.`)).toBeNull()
// Save is disabled because the prefilled config is invalid — but no error yet.
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
})
it('navigates to the in-app plans grid from the plan card and back', async () => {
const fixture = billingDevFixtures['free-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'View plans' }))
expect(await screen.findByText('Plans')).toBeTruthy()
// No subscription → the free tier is the inert current plan, the three paid
// tiers are "Choose ↗" upgrades (no "subscribe to Free").
expect(screen.getByText('Current plan')).toBeTruthy()
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(3)
expect(screen.queryByRole('button', { name: 'Downgrade' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Back to billing' }))
expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy()
})
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)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Current plan')).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)
})
it('falls back to overview (no live Choose grid) when a team deep-links bview=plans', async () => {
// Default beforeEach uses todaySubscriptionState (context: 'team') — no in-app
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a non-changer personal account deep-links bview=plans', async () => {
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment & credits')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
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 & credits')).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: '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: '$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'])
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 () => {
renderBilling()
await screen.findByRole('button', { name: 'Manage' })
// Not editing: the inputs are already in the DOM (height reserved) but aria-hidden,
// so the accessible query finds nothing while the hidden-inclusive query does.
expect(screen.queryByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold', hidden: true })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Manage' }))
// Editing reveals the same reserved input.
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({
ok: false,
refusal: {
kind: 'insufficient_scope',
message: 'billing:manage required'
}
})
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})
it('keeps disabled auto-refill portal-only with no enable control', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0)
expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy()
expect(screen.queryByRole('button', { name: /enable/i })).toBeNull()
expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull()
})
it('disables buy controls while polling and renders the settled outcome', async () => {
let settleStatus: (value: unknown) => void = () => {}
const statusPromise = new Promise(resolve => {
settleStatus = resolve
})
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
apiMocks.charge.mockResolvedValue({
data: {
charge_id: 'ch_123',
ok: true
},
idempotencyKey: 'key-1',
ok: true
})
apiMocks.chargeStatus.mockReturnValue(statusPromise)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: /^Buy$/ }))
expect(await screen.findByText('Processing… checking settlement')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
settleStatus({
data: {
amount_usd: '25',
ok: true,
status: 'settled'
},
ok: true
})
await waitFor(() => expect(screen.getByText(`${formatMoney(25)} added. Balance is refreshing.`)).toBeTruthy())
})
it('renders logged-out as a connect card without normal account rows', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(loggedOutBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(loggedOutSubscriptionState))
renderBilling()
expect(await screen.findByText('Connect your Nous account')).toBeTruthy()
expect(screen.getByText('Run /portal in the TUI or open the Nous portal to connect your account.')).toBeTruthy()
expect(screen.queryByText('Payment method')).toBeNull()
expect(screen.queryByText('Usage')).toBeNull()
})
it('renders danger value text for overdrawn subscription credits', async () => {
const fixture = billingDevFixtures['empty-overdrawn']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
// Plain shared primitive track (no bespoke dither/tinted chrome); the
// over-limit signal rides the destructive fill instead.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
const fixture = billingDevFixtures['no-subscription']
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({
...todayBillingState,
monthly_cap: {
...todayBillingState.monthly_cap,
spent_display: '$0',
spent_this_month_usd: '0'
}
})
)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
await screen.findByText('Subscription credits')
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits usage' })
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
// Empty tracks are the plain shared primitive now — no hatched placeholder.
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
})
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect(await screen.findByText('No payment method on file')).toBeTruthy()
expect(
screen.getByText(
'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.'
)
).toBeTruthy()
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
})
it('does not show the no-card notice when a card is on file', async () => {
renderBilling()
await screen.findByText('$996.47')
expect(screen.queryByText('No payment method on file')).toBeNull()
})
it('polls billing on an interval without a manual refresh control', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
// The manual refresh affordance is gone — the queries poll on their own.
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
expect(screen.queryByText(/Updated/)).toBeNull()
})
})
@@ -0,0 +1,584 @@
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
import {
ListRow,
ListRowSkeleton,
SectionHeading,
SectionHeadingSkeleton,
SettingsContent,
SettingsSection
} from '../primitives'
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 { StepUpInlineAction } from './inline-feedback'
import { openExternal } from './open-external'
import { BillingPlansView } from './plans-view'
import { createSimulatedBillingApi } from './simulated-api'
import type { BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
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]
const FEATURE_BILLING_INVOICES = false
const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
? (Object.keys(billingDevFixtures) as BillingDevFixtureName[])
: []
type BillingFixtureSelection = 'live' | BillingDevFixtureName
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
<div className="min-w-0">
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</div>
<div
className={cn(
'mt-1 min-w-0 truncate text-lg font-semibold tabular-nums',
tone === 'primary' ? 'text-(--ui-green)' : tone === 'muted' ? 'text-(--ui-text-tertiary)' : 'text-foreground'
)}
>
{value}
</div>
</div>
)
}
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
const warn = notice.tone === 'warn'
return (
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
<div
className={cn(
'text-[length:var(--conversation-text-font-size)] font-medium',
warn ? 'text-(--ui-yellow)' : 'text-foreground'
)}
>
{notice.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
{notice.action && (
<Button
className="mt-3"
onClick={() => openExternal(notice.action?.url)}
size="sm"
type="button"
variant="outline"
>
{notice.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
// The payment method as it rides in the "Payment & credits" heading: the current
// card (muted) plus a single underline text action (Update / Add payment method).
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
return (
<div className="flex min-w-0 items-center gap-2.5">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
{row.value}
</span>
)}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
size="sm"
type="button"
variant="textStrong"
>
{row.action.label}
</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} />
}
if (row.id === 'auto_reload' && billing?.auto_reload) {
return <AutoReloadRow autoReload={billing.auto_reload} bounds={billing} row={row} />
}
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>
) : undefined
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
const presets = useMemo(
() =>
billing.charge_presets.map((amount, index) => ({
amount,
label: billing.charge_presets_display[index] || formatMoney(amount)
})),
[billing.charge_presets, billing.charge_presets_display]
)
const initialAmount = presets[0]?.amount ?? billing.min_usd ?? ''
const [amount, setAmount] = useState(initialAmount)
const flow = useChargeFlow()
const busy = flow.phase === 'charging' || flow.phase === 'polling'
const controlsDisabled = busy || !billing.card
const clampedAmount = clampAmount(amount, billing)
const canBuy = !controlsDisabled && clampedAmount !== ''
const startBuy = () => {
if (!canBuy) {
return
}
setAmount(clampedAmount)
void flow.start(clampedAmount)
}
return (
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
<SegmentedControl
disabled={controlsDisabled}
onChange={value => setAmount(value)}
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
value={amount}
/>
<Input
aria-label="Custom credit amount"
containerClassName="w-16"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
min={billing.min_usd ?? undefined}
onBlur={() => setAmount(clampedAmount)}
onChange={event => {
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ?? ''}
prefix="$"
size="xs"
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
</Button>
</div>
}
below={
<BuyCreditsOutcome
amount={clampedAmount}
busy={busy}
onPortal={openExternal}
onRetry={() => {
if (!clampedAmount) {
return
}
void flow.start(clampedAmount)
}}
outcome={flow.outcome}
/>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsOutcome({
amount,
busy,
onPortal,
onRetry,
outcome
}: {
amount: string
busy: boolean
onPortal: (url?: string) => void
onRetry: () => void
outcome: ReturnType<typeof useChargeFlow>['outcome']
}) {
const stepUp = useStepUpFlow()
if (busy) {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Processing… checking settlement
</div>
)
}
if (!outcome) {
return null
}
if (outcome.kind === 'success') {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
</div>
)
}
if (outcome.kind === 'ambiguous') {
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>
{outcome.title}: {outcome.message}
</span>
{outcome.portalUrl && (
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
const portalUrl = outcome.action?.type === 'portal' ? outcome.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>
{outcome.title}: {outcome.message}
</span>
{outcome.action?.type === 'retry' && (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
</Button>
)}
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
state: 'neutral',
tone: 'topup',
value: 0
}
// Plain shared primitive — no bespoke track chrome. Only the fill tone carries
// billing meaning: destructive when over-limit, green for healthy remaining
// credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
return (
<Progress
aria-label={resolvedBar.label}
destructive={resolvedBar.state === 'danger'}
fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
size="lg"
value={resolvedBar.value}
/>
)
}
function UsageRow({ row }: { row: BillingUsageRowView }) {
return (
<div className="@container">
<div className="grid min-w-0 gap-2 py-3 @2xl:grid-cols-[minmax(0,180px)_minmax(0,1fr)_220px] @2xl:items-center @2xl:gap-4">
<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)] text-(--ui-text-tertiary)">
{row.caption}
</div>
</div>
<div className="min-w-0">
<UsageBar bar={row.bar} fallbackLabel={row.title} />
</div>
<div
className={cn(
'min-w-0 whitespace-nowrap text-[length:var(--conversation-text-font-size)] font-medium tabular-nums @2xl:w-[220px] @2xl:flex-none @2xl:text-right',
row.bar?.state === 'danger' ? 'text-destructive' : 'text-foreground'
)}
>
{row.value}
</div>
</div>
</div>
)
}
// DEV-only preview switcher: swaps the whole page onto a canned fixture so every
// billing state can be reviewed without a matching live account. Marked with a
// wrench + "preview" so it never reads as a shipping control (it's compiled out of
// production builds entirely).
function BillingFixtureSelect({
onValueChange,
value
}: {
onValueChange: (value: BillingFixtureSelection) => void
value: BillingFixtureSelection
}) {
return (
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
<Wrench className="size-3.5 shrink-0" />
<span className="text-xs font-normal">preview</span>
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing preview fixture (dev only)"
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
function BillingHeader({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
return (
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<div className="flex min-w-0 items-center gap-2">
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
<span>Billing</span>
</div>
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
) : null}
</div>
)
}
// Loading shape for the billing overview: three summary cards over the Plan /
// Payment & credits / Usage sections. Rendered under the real header.
function BillingSkeleton() {
return (
<>
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{[0, 1, 2].map(i => (
<div className="min-w-0 space-y-2" key={i}>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-20" />
</div>
))}
</div>
</div>
{[0, 1, 2].map(section => (
<section className="mb-6" key={section}>
<SectionHeadingSkeleton />
<div className="grid gap-1">
<ListRowSkeleton />
<ListRowSkeleton />
</div>
</section>
))}
</>
)
}
function BillingSettingsContent({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
// 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()
// First load keeps the page's shape via a skeleton instead of flashing "—"
// summary cards (background refetches leave `isPending` false, so no flicker).
if (billingState.isPending) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingSkeleton />
</SettingsContent>
)
}
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const { paymentRow, refillRow, topupRow } = view
// The payment method rides in the section header (right-aligned) — the
// "Payment & credits" title already names it, so a full labelled row would just
// repeat "Payment method". The stacked rows are the remaining money controls.
const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined)
// Gate the plans sub-view on the SAME capability that renders the in-app button
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
// reach a grid of live Choose buttons — it falls back to the overview.
const showPlans = subView === 'plans' && view.status === 'normal' && Boolean(view.plan?.action)
if (showPlans) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingPlansView onBack={() => setSubView('overview')} tiers={view.tiers} />
</SettingsContent>
)
}
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
{view.notice && <NoticeCard notice={view.notice} />}
<div className="@container mb-6">
<div className="grid gap-3 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</div>
{view.plan && (
<SettingsSection icon={Package} title="Plan">
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsSection>
)}
{(paymentRow || accountRows.length > 0) && (
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</SettingsSection>
)}
{view.usageRows.length > 0 && (
<SettingsSection icon={BarChart3} title="Usage">
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
</div>
</SettingsSection>
)}
{
// no endpoint yet — NAS capability-board gap
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
}
</SettingsContent>
)
}
function BillingSettingsWithDevFixtures() {
const [fixtureName, setFixtureName] = useState<BillingFixtureSelection>('live')
const queryClient = useQueryClient()
// 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() {
if (import.meta.env.DEV) {
return <BillingSettingsWithDevFixtures />
}
return <BillingSettingsContent />
}
@@ -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>
)
}
@@ -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)
}
}
@@ -0,0 +1,229 @@
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
import { type DowngradePhase, useDowngradeFlow } from './use-subscription-change'
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 bg-(--ui-bg-elevated) 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.
// eslint-disable-next-line no-restricted-syntax -- tracks previous confirming state for focus return, not an atom mirror
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 p-4 outline-none',
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
)}
ref={cardRef}
tabIndex={-1}
>
<div className="flex min-w-0 items-center gap-3">
<TierArt name={tier.name} />
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}/mo
</div>
</div>
</div>
{tier.creditsDisplay && (
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.creditsDisplay}
</div>
)}
<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}
<ExternalLink className="size-3.5" />
</Button>
)}
{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>
))}
</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"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<span>Plans</span>
</div>
{tiers.length > 0 ? (
<div className="grid gap-3 @lg:grid-cols-2 @3xl:grid-cols-3">
{tiers.map(tier => (
<PlanCard flow={flow} key={tier.tierId} tier={tier} />
))}
</div>
) : (
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}
</div>
)
}
@@ -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()
})
})
@@ -0,0 +1,89 @@
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 })
}
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveTierArt } from './tier-art'
describe('resolveTierArt', () => {
it('keys art by lowercase tier name, case-insensitively', () => {
for (const name of ['Free', 'starter', 'Plus', 'SUPER', 'ultra']) {
expect(resolveTierArt(name)).not.toBeNull()
}
})
it('maps each named tier to its NAS blend mode', () => {
expect(resolveTierArt('free')?.blend).toBe('screen')
expect(resolveTierArt('plus')?.blend).toBe('screen')
expect(resolveTierArt('super')?.blend).toBe('lighten')
expect(resolveTierArt('ultra')?.blend).toBe('normal')
})
it('returns null for unknown or missing names so the card renders text-only', () => {
expect(resolveTierArt('Mystery')).toBeNull()
expect(resolveTierArt('')).toBeNull()
expect(resolveTierArt(null)).toBeNull()
expect(resolveTierArt(undefined)).toBeNull()
})
})
@@ -0,0 +1,69 @@
import automationArt from '@/assets/tiers/feature-automation.webp'
import connectArt from '@/assets/tiers/feature-connect.webp'
import memoryArt from '@/assets/tiers/feature-memory.webp'
import sandboxArt from '@/assets/tiers/feature-sandbox.webp'
import { cn } from '@/lib/utils'
// Reproduces the portal's tier-card hero treatment at thumbnail size: each webp sits
// over a solid Nous-blue well and blends into it. This blue well is the ONLY place
// Nous blue appears in the billing page — everything else stays on the app's own tokens.
const NOUS_BLUE = '#0000f2'
const BLEND_CLASS = {
lighten: 'mix-blend-lighten',
normal: '',
screen: 'mix-blend-screen'
} as const
type TierBlend = keyof typeof BLEND_CLASS
interface TierArtSpec {
blend: TierBlend
src: string
}
// Keyed by lowercase tier NAME, not tier_id: real tier_ids are Prisma cuids that
// differ per environment, while names are stable. `free`/`starter` share the
// entry-tier art. An unknown name resolves to null → the card renders text-only.
const TIER_ART: Record<string, TierArtSpec> = {
free: { blend: 'screen', src: connectArt },
plus: { blend: 'screen', src: memoryArt },
starter: { blend: 'screen', src: connectArt },
super: { blend: 'lighten', src: automationArt },
ultra: { blend: 'normal', src: sandboxArt }
}
export function resolveTierArt(tierName?: null | string): null | TierArtSpec {
if (!tierName) {
return null
}
return TIER_ART[tierName.trim().toLowerCase()] ?? null
}
/**
* Small rounded thumbnail (~40px) rendering the tier art over a Nous-blue well.
* Returns null for unknown tiers so the caller lays out a text-only card without
* reserving empty art space. Imported via vite static imports so the URLs resolve
* under a packaged `file://` origin with webSecurity on.
*/
export function TierArt({ className, name, size = 40 }: { className?: string; name?: null | string; size?: number }) {
const art = resolveTierArt(name)
if (!art) {
return null
}
return (
<div
className={cn('relative shrink-0 overflow-hidden rounded-md', className)}
style={{ background: NOUS_BLUE, height: size, width: size }}
>
<img
alt=""
className={cn('pointer-events-none absolute inset-0 size-full max-w-none object-cover', BLEND_CLASS[art.blend])}
src={art.src}
/>
</div>
)
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
const fullBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '10000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
ok: true,
org_name: 'Acme Research',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
const deployedTodayBillingState = {
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: {
brand: 'mastercard',
last4: '4444',
masked: 'mastercard ....4444'
},
charge_presets: [],
charge_presets_display: [],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: null,
min_usd: null,
monthly_cap: null,
ok: true,
org_name: 'Fresh Deploy',
portal_url: null,
role: 'OWNER'
} satisfies BillingStateResponse
const loggedOutSubscriptionState = {
can_change_plan: false,
context: 'personal',
current: null,
is_admin: false,
logged_in: false,
ok: true,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: []
} satisfies SubscriptionStateResponse
describe('desktop billing wire types', () => {
it('pins realistic billing and subscription RPC payload shapes', () => {
expect(fullBillingState.card?.resolved_via).toBe('subPin')
expect(deployedTodayBillingState.can_charge).toBe(false)
expect(deployedTodayBillingState.cli_billing_enabled).toBe(false)
expect(deployedTodayBillingState.card?.last4).toBe('4444')
expect(loggedOutSubscriptionState.logged_in).toBe(false)
})
})
@@ -0,0 +1,35 @@
import type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
} from '@hermes/shared/billing'
export type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
}
@@ -0,0 +1,765 @@
import { describe, expect, it } from 'vitest'
import {
billingDevFixtures,
endpointUnavailableBilling,
endpointUnavailableSubscription,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { buildManageSubscriptionUrl, deriveBillingView, formatMonthlyCreditsDelta } from './use-billing-state'
function usageRowFor(
fixtureName: keyof typeof billingDevFixtures,
rowId: 'monthly_cap' | 'subscription_credits' | 'topup_credits'
) {
const fixture = billingDevFixtures[fixtureName]
const view = deriveBillingView(fixture.billing, fixture.subscription)
return view.usageRows.find(row => row.id === rowId)
}
function subscriptionCreditsRowForRemaining(remaining: string) {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
current: { ...todaySubscriptionState.current, credits_remaining: remaining, monthly_credits: '220' }
})
)
return view.usageRows.find(row => row.id === 'subscription_credits')
}
function monthlyCapRowForSpent(spent: string) {
const view = deriveBillingView(
okBilling({
...todayBillingState,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$100',
limit_usd: '100',
spent_display: `$${spent}`,
spent_this_month_usd: spent
}
}),
okSubscription(todaySubscriptionState)
)
return view.usageRows.find(row => row.id === 'monthly_cap')
}
describe('deriveBillingView', () => {
it('derives the deployed-today shape with fail-open disabled charge controls', () => {
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(view.status).toBe('normal')
expect(view.summary).toContainEqual({ label: 'Balance', value: '$996.47' })
expect(view.summary).toContainEqual({ label: 'Plan', value: 'Ultra · $200/mo' })
expect(view.topupRow?.description).toBe(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
expect(view.topupRow?.chips).toBeUndefined()
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap'])
})
it('derives the post-train shape with card provenance, presets, and denominated usage bars', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
expect(view.status).toBe('normal')
expect(view.paymentRow?.value).toBe('Visa •••• 4242 - subscription card')
expect(view.topupRow?.chips?.map(chip => chip.label)).toEqual(['$25', '$50', '$100'])
expect(view.plan?.link?.url).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
expect(view.usageRows.find(row => row.id === 'subscription_credits')).toMatchObject({
bar: { value: 0.4 },
value: '$40 of $100 left'
})
})
it('points divergent auto-refill cards at the portal for reconciliation', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
auto_reload: {
...todayBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_1', brand: 'mastercard', last4: '4444' }
}
}),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow?.caption).toContain('Mastercard ••4444')
expect(view.refillRow?.caption).toContain('reconcile')
expect(view.refillRow?.action).toEqual({
label: 'Reconcile ↗',
url: 'https://portal.nousresearch.com/billing'
})
})
it('degrades safely when a divergent auto-refill card has no display details', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
auto_reload: {
...todayBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_1', brand: null, last4: null }
}
}),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow?.caption).toContain('a different card')
expect(view.refillRow?.caption).not.toContain('null')
expect(view.refillRow?.action?.url).toBe('https://portal.nousresearch.com/billing')
})
it('renders the normal enabled auto-refill row when the card is null (no crash)', () => {
// The gateway emits auto_reload.card: null for a missing/unknown-kind card.
const view = deriveBillingView(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } }),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
})
it('keeps buy credit controls visible but disabled when no card is on file', () => {
const fixture = billingDevFixtures['no-card']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const buyCredits = view.topupRow
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
// The no-card blocker is explained once by the page-level notice, not
// duplicated (emoji and all) into the row description.
description: 'A single charge on your card, added to your balance today.'
})
expect(buyCredits?.description).not.toContain('💳')
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
// The page still leads with the warn banner naming the blocker + fix.
expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
})
it('derives a calm logged-out card with no account or usage rows', () => {
const view = deriveBillingView(okBilling(loggedOutBillingState), okSubscription(loggedOutSubscriptionState))
expect(view.status).toBe('logged_out')
expect(view.summary.map(item => item.value)).toEqual(['—', '—', '—'])
expect(view.notice).toMatchObject({
title: 'Connect your Nous account'
})
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
expect(view.usageRows).toEqual([])
})
it('derives a refusal notice when billing.state is unavailable', () => {
const view = deriveBillingView(endpointUnavailableBilling, okSubscription(todaySubscriptionState))
expect(view.status).toBe('refusal')
expect(view.summary.map(item => item.value)).toEqual(['—', '—', '—'])
expect(view.notice).toMatchObject({
title: 'Billing endpoint unavailable'
})
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
})
it('keeps subscription unavailable as a plan-card degradation with a live portal link', () => {
const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription)
expect(view.status).toBe('normal')
expect(view.plan).toMatchObject({
caption: 'Subscription details are unavailable; opening the portal is still available.',
tierName: 'Ultra'
})
expect(view.plan?.action).toBeUndefined()
// The caption promises the portal is still reachable — so the link must exist.
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription'
})
})
it('clamps overdrawn subscription credits to $0 and names the overage', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
current: { ...todaySubscriptionState.current, credits_remaining: '-0.79', monthly_credits: '220' }
})
)
const row = view.usageRows.find(r => r.id === 'subscription_credits')
expect(row?.value).toBe('$0 of $220 left · $0.79 over')
expect(row?.bar?.value).toBe(0)
})
it('marks subscription remaining bars as ok above 10% and danger at or below 10%', () => {
const elevenPercent = subscriptionCreditsRowForRemaining('24.2')
expect(elevenPercent?.bar?.state).toBe('ok')
expect(elevenPercent?.bar?.value).toBeCloseTo(0.11)
expect(usageRowFor('healthy', 'subscription_credits')?.bar).toMatchObject({
state: 'ok',
value: 0.6
})
// Owner wording is "green until 10%, then red"; the exact 10% boundary is red.
expect(usageRowFor('boundary', 'subscription_credits')?.bar).toMatchObject({
state: 'danger',
value: 0.1
})
expect(usageRowFor('low', 'subscription_credits')?.bar).toMatchObject({
state: 'danger',
value: 0.09
})
})
it('marks empty or overdrawn subscription bars as danger with a full danger track', () => {
const row = usageRowFor('empty-overdrawn', 'subscription_credits')
expect(row?.value).toBe('$0 of $220 left · $0.79 over')
expect(row?.bar).toMatchObject({
state: 'danger',
track: 'danger',
value: 0
})
})
it('marks monthly cap bars as neutral below 90% and danger at or above 90%', () => {
expect(usageRowFor('healthy', 'monthly_cap')?.bar).toMatchObject({
state: 'ok',
value: 0.89
})
expect(monthlyCapRowForSpent('90')?.bar).toMatchObject({
state: 'danger',
value: 0.9
})
expect(usageRowFor('cap-near', 'monthly_cap')?.bar).toMatchObject({
state: 'danger',
value: 0.92
})
expect(usageRowFor('cap-hit', 'monthly_cap')?.bar).toMatchObject({
state: 'danger',
track: 'danger',
value: 1
})
})
it('renders top-up balance as a bare amount — no bar (no denominator exists)', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
const topup = view.usageRows.find(row => row.id === 'topup_credits')
expect(topup?.value).toBe('$75')
expect(topup?.bar).toBeUndefined()
})
it('renders zero top-up balance without a bar too', () => {
const view = deriveBillingView(
okBilling({
...todayBillingState,
balance_display: '$0',
balance_usd: '0',
usage: {
...todayBillingState.usage,
topup_remaining_display: '$0'
}
}),
undefined
)
const topup = view.usageRows.find(row => row.id === 'topup_credits')
expect(topup?.value).toBe('$0')
expect(topup?.bar).toBeUndefined()
})
})
describe('derivePlanCard (current-plan card)', () => {
it('offers an in-app "View plans" button for a free personal account that can change plans', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'View plans' }, tierName: 'Free' })
expect(view.plan?.link).toBeUndefined()
})
it('offers an in-app "Change plan" button for a personal subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'Change plan' }, price: '$20', tierName: 'Plus' })
expect(view.plan?.link).toBeUndefined()
})
it('gives teams a portal escape hatch and no in-app button', () => {
// todaySubscriptionState is context: 'team'.
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('gives non-changing members a portal link but no in-app button', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('withholds the in-app button (no dead click) and offers the portal link when nothing is actionable', () => {
// A subscriber whose only enabled tier is the one they are already on: the grid
// would show a single inert card, so the "Change plan" button must not appear.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'solo', tier_name: 'Solo' },
tiers: [
{
dollars_per_month_display: '$10',
is_current: true,
is_enabled: true,
monthly_credits: '10',
name: 'Solo',
tier_id: 'solo',
tier_order: 0
}
]
})
)
expect(view.tiers.map(tier => tier.state)).toEqual(['current'])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
it('gives a top-tier subscriber a portal link, not a dead in-app button', () => {
// 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({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'top', 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
}
]
})
)
expect(view.tiers.some(tier => tier.state === 'upgrade')).toBe(false)
// 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', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
tiers: []
})
)
expect(view.tiers).toEqual([])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
})
describe('derivePlanTiers (plans grid)', () => {
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.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)
expect(byName.Super).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_plus&plan=cltier222super222personal'
},
creditsDisplay: '$110 credits/mo',
state: 'upgrade'
})
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)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// 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)
// No downgrade state can exist without a subscription.
expect(view.tiers.some(tier => tier.state === 'downgrade')).toBe(false)
expect(byName.Plus).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_free&plan=cltier111plus1111personal'
}
})
})
it('still lists a tier whose name has no art mapping (text-only card, no layout break)', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier_free_0000',
tier_order: 0
},
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Mystery',
tier_id: 'cltier_mystery_0000',
tier_order: 5
}
]
})
)
// The unknown-named paid tier still lists (art resolves to null → text-only).
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Mystery'])
expect(view.tiers.find(tier => tier.name === 'Mystery')?.state).toBe('upgrade')
})
it('keeps a grandfathered (is_enabled:false) CURRENT tier inert and orders downgrades against it', () => {
// NAS marks a grandfathered current tier is_enabled:false. It must still appear
// (inert "Current plan") and define the order boundary — lower enabled tiers are
// downgrades, higher ones are Choose.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'legacy_mid', tier_name: 'Legacy' },
tiers: [
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Basic',
tier_id: 'basic',
tier_order: 0
},
{
dollars_per_month_display: '$15',
is_current: true,
is_enabled: false,
monthly_credits: '15',
name: 'Legacy',
tier_id: 'legacy_mid',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '40',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Basic', 'Legacy', 'Ultra'])
expect(byName.Legacy.state).toBe('current')
expect('action' in byName.Legacy).toBe(false)
expect(byName.Basic.state).toBe('downgrade')
expect('action' in byName.Basic).toBe(false)
expect(byName.Ultra).toMatchObject({ action: { label: 'Choose ↗' }, state: 'upgrade' })
})
it('backs Choose URLs with billing.portal_url (org_id + plan intact) when the subscription has no portal_url', () => {
const view = deriveBillingView(
okBilling({ ...todayBillingState, portal_url: 'https://billing.example.com/x' }),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
portal_url: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free0',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus1',
tier_order: 1
}
]
})
)
expect(view.tiers.find(tier => tier.name === 'Plus')).toMatchObject({
action: { url: 'https://billing.example.com/manage-subscription?org_id=sid-5&plan=plus1' }
})
})
it('drops grandfathered (is_enabled: false) tiers from the grid', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$9',
is_current: false,
is_enabled: false,
monthly_credits: '9',
name: 'Legacy',
tier_id: 'legacy',
tier_order: 1
}
]
})
)
expect(view.tiers.map(tier => tier.name)).toEqual(['Plus'])
})
})
describe('buildManageSubscriptionUrl', () => {
it('mirrors the TUI manage-subscription URL construction', () => {
expect(
buildManageSubscriptionUrl({
org_id: 'org_123',
portal_url: 'https://portal.nousresearch.com/billing'
})
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('appends plan=<tierId> when a tier is chosen', () => {
expect(
buildManageSubscriptionUrl(
{ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' },
null,
'tier_abc'
)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=tier_abc')
})
it('omits the plan param when no tier is given', () => {
expect(
buildManageSubscriptionUrl({ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' }, null)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('applies org_id + plan to the hard-coded portal fallback when no portal_url resolves', () => {
// Regression: the fallback must be the last-resort ORIGIN, not a bare return that
// silently drops org_id/plan.
expect(buildManageSubscriptionUrl({ org_id: 'org_z', portal_url: null }, null, 'tier_q')).toBe(
'https://portal.nousresearch.com/manage-subscription?org_id=org_z&plan=tier_q'
)
})
})
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()
})
})
@@ -0,0 +1,840 @@
import { useQuery } from '@tanstack/react-query'
import { fmtDate } from '@/lib/time'
import type { BillingRefusal, BillingResult } from './api'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
// The billing endpoint is the authoritative source of truth for balance / cap /
// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
// out of sync (notably in team/org accounts where another member's spend moves
// the shared balance without ever touching THIS client's headers). So the page
// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
// fresh fetch every time it opens or regains focus, and it keeps polling every
// 30s while mounted (react-query only ticks an active observer; it pauses when
// the window is backgrounded — refetchIntervalInBackground defaults to false).
// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
// pull the change in immediately rather than waiting for the next poll tick.
const BILLING_QUERY_OPTIONS = {
refetchInterval: 30_000,
refetchOnMount: 'always',
refetchOnWindowFocus: true,
retry: false,
staleTime: 0
} as const
export interface BillingSummaryItemView {
label: 'Auto-refill' | 'Balance' | 'Plan'
tone?: 'muted' | 'primary'
value: string
}
export interface BillingNoticeView {
action?: {
label: string
url: string
}
message: string
title: string
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
tone?: 'info' | 'warn'
}
export interface BillingRowActionView {
disabled?: boolean
label: string
url?: string
}
export interface BillingChipView {
disabled: boolean
label: string
/** When set, clicking the chip opens this URL externally. */
url?: string
}
export interface BillingAccountRowView {
action?: BillingRowActionView
caption?: string
chips?: BillingChipView[]
description: string
id: 'auto_reload' | 'buy_credits' | 'payment_method'
/** The auto-refill row that edits its amounts in place (canonical-card enabled). */
manageInApp?: true
pill?: {
label: string
tone: 'muted' | 'primary'
}
secondaryPill?: string
title: string
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
* ↗"), never both — a discriminated pair so consumers don't guard for the impossible
* "both present" / "neither present" cases.
*/
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 } })
interface BillingPlanTierBase {
creditsDisplay?: string
name: string
priceDisplay: string
tierId: string
}
/**
* 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 & { state: 'downgrade' })
| (BillingPlanTierBase & { state: 'scheduled' })
| (BillingPlanTierBase & { action: { label: string; url: string }; state: 'upgrade' })
export interface BillingUsageRowView {
bar?: {
label: string
state: 'danger' | 'neutral' | 'ok'
tone: 'cap' | 'subscription' | 'topup'
track?: 'danger'
value: number
}
caption: string
id: 'monthly_cap' | 'subscription_credits' | 'topup_credits'
title: string
value: string
}
export interface BillingView {
notice?: BillingNoticeView
/** Payment section row. Absent outside the normal (logged-in) state. */
paymentRow?: BillingAccountRowView
/** Current-plan card (Plan section). Absent until billing.state resolves. */
plan?: BillingPlanCardView
/** Automatic-refill section row. */
refillRow?: BillingAccountRowView
status: 'loading' | 'logged_out' | 'normal' | 'refusal'
summary: BillingSummaryItemView[]
/** Live tier catalog for the plans sub-view (empty when unavailable). */
tiers: BillingPlanTierView[]
/** One-time top-up section row. */
topupRow?: BillingAccountRowView
usageRows: BillingUsageRowView[]
}
export function useBillingState(enabled = true) {
const api = useBillingApi()
return useQuery({
...BILLING_QUERY_OPTIONS,
enabled,
queryFn: () => api.fetchBillingState(),
queryKey: ['billing', 'state']
})
}
export function useSubscriptionState(enabled = true) {
const api = useBillingApi()
return useQuery({
...BILLING_QUERY_OPTIONS,
enabled,
queryFn: () => api.fetchSubscriptionState(),
queryKey: ['billing', 'subscription']
})
}
export function deriveBillingView(
stateResult?: BillingResult<BillingStateResponse>,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
): BillingView {
if (!stateResult) {
return {
status: 'loading',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
if (!stateResult.ok) {
return {
notice: refusalNotice(stateResult.refusal),
status: 'refusal',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
const billing = stateResult.data
const subscription = subscriptionResult?.ok ? subscriptionResult.data : null
if (!billing.logged_in || subscription?.logged_in === false) {
return {
notice: {
action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
message: 'Run /portal in the TUI or open the Nous portal to connect your account.',
title: 'Connect your Nous account'
},
status: 'logged_out',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
// One "can change plans in-app" verdict, shared by the plan card (button vs portal
// link) and the grid (whether upgrade tiles are actionable) so the invariant lives
// in one place.
const capable = plansCapable(subscription, subscriptionResult)
// 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: noCardNotice(billing),
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
status: 'normal',
summary: [
{ label: 'Balance', value: displayBalance(billing) },
{ label: 'Plan', value: displayPlan(subscription, billing.usage) },
{
label: 'Auto-refill',
tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined,
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
}
],
tiers,
topupRow: buyCreditsRow(billing),
usageRows: deriveUsageRows(billing, subscription)
}
}
export function buildManageSubscriptionUrl(
subscription?: null | Pick<SubscriptionStateResponse, 'org_id' | 'portal_url'>,
fallbackPortalUrl?: null | string,
// Optional tier to pre-select on the portal, appended as `plan=<tierId>`
// (validated server-side by the NAS reader, draft #748).
tierId?: null | string
): string {
// The hard-coded portal is the LAST-RESORT origin, not a bare early return:
// org_id / plan must still be applied to it so a null portal_url never silently
// strips the params that route the user to the right org + pre-selected tier.
const portalUrls = [subscription?.portal_url, fallbackPortalUrl, FALLBACK_PORTAL_BILLING_URL].filter(
(url): url is string => typeof url === 'string' && url.length > 0
)
for (const portalUrl of portalUrls) {
try {
const url = new URL('/manage-subscription', new URL(portalUrl).origin)
if (subscription?.org_id) {
url.searchParams.set('org_id', subscription.org_id)
}
if (tierId) {
url.searchParams.set('plan', tierId)
}
return url.toString()
} catch {
// Try the next candidate; malformed portal URLs should not break settings.
}
}
return FALLBACK_PORTAL_BILLING_URL
}
export function formatBillingDate(value?: null | string): string {
if (!value) {
return EMPTY_BILLING_VALUE
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return EMPTY_BILLING_VALUE
}
return fmtDate.format(date)
}
function emptySummary(): BillingSummaryItemView[] {
return [
{ label: 'Balance', value: EMPTY_BILLING_VALUE },
{ label: 'Plan', value: EMPTY_BILLING_VALUE },
{ label: 'Auto-refill', value: EMPTY_BILLING_VALUE }
]
}
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return {
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
message: resolved.message,
title: resolved.title,
tone: 'warn'
}
}
// A logged-in account with no card can't buy credits or manage auto-refill, and
// every one of those controls disables silently — so lead the page with a single
// warn banner that names the blocker and links straight to the fix.
function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined {
if (billing.card) {
return undefined
}
return {
action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
title: 'No payment method on file',
tone: 'warn'
}
}
// The active tier from the UNFILTERED catalog — a grandfathered current tier is
// is_enabled:false, so it must still resolve here (by is_current or matching id).
function findCurrentTier(subscription: null | SubscriptionStateResponse): SubscriptionTierOption | undefined {
const current = subscription?.current
return subscription?.tiers?.find(tier => tier.is_current || tier.tier_id === current?.tier_id)
}
// Whether this account can change plans in-app: a personal (non-team) subscription
// the server says the user can change, whose payload actually loaded.
function plansCapable(
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined
): boolean {
if (!subscription || (subscriptionResult && !subscriptionResult.ok)) {
return false
}
return subscription.context !== 'team' && Boolean(subscription.can_change_plan)
}
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
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
* — a top-tier subscriber (only downgrades / current below them) would otherwise open
* a grid with nothing to do. In every no-button case (teams, non-changers, refused
* subscription, top tier, empty catalog) the card ALWAYS carries the portal
* escape-hatch link so the user is never stranded on an info-only card.
*/
function derivePlanCard(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
tiers: BillingPlanTierView[],
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanCardView {
const current = subscription?.current
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
// still shows its price.
const price = findCurrentTier(subscription)?.dollars_per_month_display
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
const caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: 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 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, pending, price, tierName }
}
return {
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
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 = 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.
*
* Empty unless `capable`: only a plans-capable account gets actionable tiles, and the
* plan card / deep-link gate on the same verdict — so the grid never mints an
* upgrade action nobody may take. `fallbackPortalUrl` (billing.portal_url) backs the
* Choose URLs when the subscription payload has no portal_url, so org_id + plan are
* never dropped.
*/
function derivePlanTiers(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl: null | string,
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
}
const allTiers = subscription.tiers ?? []
const current = subscription.current
const explicitCurrent = findCurrentTier(subscription)
// The grid shows the enabled catalog plus the grandfathered current tier (so it
// still renders as the inert "Current plan" card), sorted low→high.
const gridTiers = allTiers
.filter(tier => tier.is_enabled || tier.tier_id === explicitCurrent?.tier_id)
.slice()
.sort((a, b) => a.tier_order - b.tier_order)
if (gridTiers.length === 0) {
return []
}
// No active subscription → the lowest-order ($0 / free) tier stands in as the
// current plan: inert, never a "subscribe to Free" upgrade, and (being lowest)
// never leaving room for a downgrade.
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 = {
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
name: tier.name,
priceDisplay: tier.dollars_per_month_display,
tierId: tier.tier_id
}
if (currentTier && tier.tier_id === currentTier.tier_id) {
return { ...base, state: 'current' }
}
// 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, state: 'downgrade' }
}
return {
...base,
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
state: 'upgrade'
}
})
}
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
const card = billing.card
if (!card) {
// No card → a single "Add payment method" link, the way every other app does
// it. The reason (buys/auto-refill are blocked) already leads the page as a
// notice, so the row stays a bare call-to-action with no redundant status text.
return {
action: { label: 'Add payment method', url: portalUrl },
description: '',
id: 'payment_method',
title: 'Payment method'
}
}
return {
action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
id: 'payment_method',
title: 'Payment method',
value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}`
}
}
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
// The no-card blocker is already spelled out by the page-level warn banner
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
// so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
}
const disabledReason = buyCreditsDisabledReason(billing)
if (disabledReason) {
return {
description: disabledReason,
id: 'buy_credits',
title: 'Buy credits now'
}
}
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
}
// The generic first sentence shared by the off / absent / divergent states,
// where the concrete amounts aren't the headline. The configured state overrides
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const autoReload = billing.auto_reload
if (!autoReload) {
return {
action: { disabled: true, label: 'Manage' },
caption: 'Manage auto-refill from the portal.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Refill when low'
}
}
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
title: 'Refill when low'
}
}
// A null card (gateway emits it for a missing/unknown-kind card) falls through to
// the default enabled path below — the same treatment as a canonical card.
if (autoReload.card?.kind === 'distinct') {
const { brand, last4 } = autoReload.card
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
return {
action: { label: 'Reconcile ↗', url: portalUrl },
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
}
}
const reloadTo = autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
return {
action: { label: 'Manage' },
// Numbers live in the first sentence (spec §8); the swap region below carries
// the editable fields, so no redundant caption here.
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
id: 'auto_reload',
// The only row that edits in place — AutoReloadRow keys its swap layout off this
// flag rather than sniffing the action label.
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' },
title: 'Refill when low'
}
}
function deriveUsageRows(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse
): BillingUsageRowView[] {
const rows: BillingUsageRowView[] = []
const current = subscription?.current
const remaining = parseAmount(current?.credits_remaining)
const monthly = parseAmount(current?.monthly_credits)
const usage = subscription?.usage ?? billing.usage
// Remaining can go slightly negative (usage settles after credits hit zero).
// A raw "-$0.79 left" reads as broken — clamp to $0 and name the overage.
const subscriptionValue =
remaining != null && monthly != null
? remaining < 0
? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over`
: `${formatMoney(remaining)} of ${formatMoney(monthly)} left`
: (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE)
const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null
rows.push({
bar:
remainingFraction != null
? {
label: 'Subscription credits remaining',
state: remainingFraction <= 0.1 ? 'danger' : 'ok',
tone: 'subscription',
track: remaining != null && remaining <= 0 ? 'danger' : undefined,
value: clamp01(remainingFraction)
}
: undefined,
caption: `Resets ${formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)}`,
id: 'subscription_credits',
title: 'Subscription credits',
value: subscriptionValue
})
const topupValue = topupCreditsValue(billing, usage)
// No bar: top-ups have no denominator (the wire carries only the current
// balance, and the pool is open-ended), so a fill fraction would be fiction.
rows.push({
caption: 'Does not expire',
id: 'topup_credits',
title: 'Top-up credits',
value: topupValue
})
const cap = billing.monthly_cap
if (cap && cap.limit_usd != null) {
const limit = parseAmount(cap.limit_usd)
const spent = parseAmount(cap.spent_this_month_usd) ?? 0
const usedFraction = limit != null && limit > 0 ? spent / limit : null
const value = `${cap.spent_display || formatMoney(spent)} of ${cap.limit_display || formatMoney(limit)} used`
rows.push({
bar:
usedFraction != null
? {
label: 'Monthly spend cap used',
state: usedFraction >= 0.9 ? 'danger' : 'ok',
tone: 'cap',
track: usedFraction >= 1 ? 'danger' : undefined,
value: clamp01(usedFraction)
}
: undefined,
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
id: 'monthly_cap',
title: 'Monthly spend cap',
value
})
}
return rows
}
function displayBalance(billing: BillingStateResponse): string {
return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd)
}
function displayPlan(subscription: null | SubscriptionStateResponse, usage?: UsageModelData): string {
const current = subscription?.current
const tier = current?.tier_name ?? usage?.plan_name
if (!tier) {
return EMPTY_BILLING_VALUE
}
const price = findCurrentTier(subscription)?.dollars_per_month_display
return price ? `${tier} · ${price}/mo` : tier
}
function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string {
return (
usage?.topup_remaining_display ??
usage?.topup_bar?.remaining_display ??
nonEmpty(billing.balance_display) ??
formatMoney(billing.balance_usd)
)
}
function buyCreditsDisabledReason(billing: BillingStateResponse): null | string {
if (!billing.is_admin) {
return resolveRefusal({ kind: 'role_required', message: '' }).message
}
if (!billing.cli_billing_enabled) {
return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
}
if (!billing.can_charge) {
return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined })
.message
}
return null
}
function provenanceSuffix(resolvedVia?: null | string): string {
if (!resolvedVia) {
return ''
}
const labels: Record<string, string> = {
autoRefill: 'auto-refill card',
customerDefault: 'customer default',
subPin: 'subscription card'
}
return ` - ${labels[resolvedVia] ?? resolvedVia}`
}
function capitalize(value: string): string {
return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : value
}
function nonEmpty(value?: null | string): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
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 : null
}
function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
// Pin en-US so the symbol is always "$" — the server's *_display strings
// ("$996.47") sit next to these, and other locales render USD as "US$".
return new Intl.NumberFormat('en-US', {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}
function clamp01(value: number): number {
if (!Number.isFinite(value)) {
return 0
}
return Math.max(0, Math.min(1, value))
}
@@ -0,0 +1,232 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BillingResult } from './api'
import type { BillingChargeStatusResponse } from './types'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn()
}))
vi.mock('./api', () => ({
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus
})
}))
import { CHARGE_POLL_CAP_MS, pollChargeSettlement, useChargeFlow } from './use-charge-poller'
const status = (overrides: Partial<BillingChargeStatusResponse> = {}): BillingResult<BillingChargeStatusResponse> => ({
data: {
ok: true,
status: 'pending',
...overrides
},
ok: true
})
const refusal = (
kind: string,
overrides: Partial<Extract<BillingResult<BillingChargeStatusResponse>, { ok: false }>['refusal']> = {}
): BillingResult<BillingChargeStatusResponse> => ({
ok: false,
refusal: {
kind,
message: kind,
...overrides
}
})
function controlledClock() {
let current = 0
const waits: number[] = []
return {
now: () => current,
sleep: vi.fn(async (ms: number) => {
waits.push(ms)
current += ms
}),
waits
}
}
function wrapper({ children }: PropsWithChildren) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return createElement(QueryClientProvider, { client }, children)
}
beforeEach(() => {
apiMocks.charge.mockReset()
apiMocks.chargeStatus.mockReset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('pollChargeSettlement', () => {
it('settles after pending polls', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(status())
.mockResolvedValueOnce(status())
.mockResolvedValueOnce(status({ amount_usd: '25', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '25', kind: 'success' })
expect(api.chargeStatus).toHaveBeenCalledTimes(3)
expect(clock.waits).toEqual([2000, 2000])
})
it('returns a failed outcome with the charge failure reason', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(status({ reason: 'card_declined', status: 'failed' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({
kind: 'failure',
message: 'Your card was declined. Try another card on the portal.',
title: 'Charge failed'
})
expect(clock.waits).toEqual([])
})
it('backs off on rate limits, honors retryAfter, and keeps polling', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(refusal('rate_limited', { retryAfter: 7 }))
.mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' })
expect(clock.waits).toEqual([7000])
})
it('backs off when Stripe is unavailable and keeps polling', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi
.fn()
.mockResolvedValueOnce(refusal('stripe_unavailable', { retryAfter: 3 }))
.mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' }))
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' })
expect(clock.waits).toEqual([3000])
})
it('caps pending polling at 5 minutes as an ambiguous outcome', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(status())
}
const outcome = await pollChargeSettlement(api, 'ch_123', {
...clock,
portalUrl: 'https://portal.nousresearch.com/billing'
})
expect(outcome).toEqual({
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
portalUrl: 'https://portal.nousresearch.com/billing',
title: 'Still processing after 5 minutes'
})
expect(clock.waits.reduce((total, ms) => total + ms, 0)).toBe(CHARGE_POLL_CAP_MS)
})
it('treats auth revocation while polling as ambiguous', async () => {
const clock = controlledClock()
const api = {
chargeStatus: vi.fn().mockResolvedValue(
refusal('session_revoked', {
message: 'Your session was logged out.',
portalUrl: 'https://portal.nousresearch.com/billing'
})
)
}
const outcome = await pollChargeSettlement(api, 'ch_123', clock)
expect(outcome).toMatchObject({
kind: 'ambiguous',
portalUrl: 'https://portal.nousresearch.com/billing',
title: 'Charge outcome unconfirmed'
})
})
})
describe('useChargeFlow', () => {
it('turns a charge refusal into an immediate outcome without polling', async () => {
apiMocks.charge.mockResolvedValue({
idempotencyKey: 'key-1',
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
const { result } = renderHook(() => useChargeFlow(), { wrapper })
await act(async () => {
await result.current.start('25')
})
expect(result.current.phase).toBe('done')
expect(result.current.outcome).toMatchObject({
kind: 'failure',
title: 'No saved card'
})
expect(apiMocks.chargeStatus).not.toHaveBeenCalled()
})
it('reuses one idempotency key when retrying a failed-to-send charge', async () => {
apiMocks.charge.mockResolvedValue({
idempotencyKey: 'key-1',
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed'
}
})
const { result } = renderHook(() => useChargeFlow(), { wrapper })
await act(async () => {
await result.current.start('25')
})
await act(async () => {
await result.current.start('25')
})
expect(apiMocks.charge).toHaveBeenNthCalledWith(1, '25', undefined)
expect(apiMocks.charge).toHaveBeenNthCalledWith(2, '25', 'key-1')
})
})
@@ -0,0 +1,297 @@
import { refusalPolicy } from '@hermes/shared/billing-policy'
import {
driveChargeSettlement,
SETTLEMENT_POLL_CAP_MS,
SETTLEMENT_POLL_INTERVAL_MS
} from '@hermes/shared/charge-settlement'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useRef, useState } from 'react'
import type { BillingApi, BillingRefusal } from './api'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
import type { BillingChargeStatusResponse } from './types'
export const CHARGE_POLL_INTERVAL_MS = SETTLEMENT_POLL_INTERVAL_MS
export const CHARGE_POLL_CAP_MS = SETTLEMENT_POLL_CAP_MS
export type ChargeFlowPhase = 'charging' | 'done' | 'idle' | 'polling'
export type ChargeFlowOutcome =
| {
amountUsd?: string | null
kind: 'success'
message: string
}
| {
action?: { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
kind: 'failure'
message: string
retryFreshKey: boolean
title: string
}
| {
kind: 'ambiguous'
message: string
portalUrl?: string
title: string
}
export interface ChargePollClock {
now?: () => number
sleep?: (ms: number) => Promise<void>
}
export interface ChargePollOptions extends ChargePollClock {
portalUrl?: null | string
}
interface PendingChargeIntent {
amountUsd: string
idempotencyKey: string
}
const defaultSleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
const retryableSendKinds = new Set([
'endpoint_unavailable',
'rate_limited',
'temporarily_unavailable',
'timeout',
'transport'
])
export async function pollChargeSettlement(
api: Pick<BillingApi, 'chargeStatus'>,
chargeId: string,
opts: ChargePollOptions = {}
): Promise<ChargeFlowOutcome> {
const sleep = opts.sleep ?? defaultSleep
const now = opts.now ?? Date.now
const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {}
const settlement = await driveChargeSettlement({
fetchStatus: async () => {
const result = await api.chargeStatus(chargeId)
if (result.ok) {
observed.refusal = undefined
observed.status = result.data
return result.data
}
observed.refusal = result.refusal
observed.status = statusFromRefusal(result.refusal)
return observed.status
},
isCancelled: () => false,
now,
sleep
})
switch (settlement.kind) {
case 'settled':
return {
amountUsd: settlement.status.amount_usd,
kind: 'success',
message: settlement.status.amount_usd ? `$${settlement.status.amount_usd} added.` : 'Credits added.'
}
case 'failed':
return {
action: { type: 'retry' },
kind: 'failure',
message: renderChargeFailed(settlement.status.reason),
retryFreshKey: true,
title: 'Charge failed'
}
case 'ambiguous': {
if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) {
const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status)
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl
return {
kind: 'ambiguous',
message: `${resolved.message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`,
portalUrl: portalUrl ?? opts.portalUrl ?? undefined,
title: 'Charge outcome unconfirmed'
}
}
return {
kind: 'failure',
message: observed.refusal?.message || 'Could not check the charge.',
retryFreshKey: true,
title: 'Could not check charge'
}
}
case 'refused':
return {
kind: 'failure',
message: observed.refusal?.message || settlement.status.message || 'Could not check the charge.',
retryFreshKey: true,
title: 'Could not check charge'
}
case 'cancelled':
case 'timed_out':
return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl)
}
}
function statusFromRefusal(refusal: BillingRefusal): BillingChargeStatusResponse {
const raw = isRecord(refusal.raw) ? refusal.raw : {}
return {
...raw,
error: refusal.kind,
message: refusal.message,
ok: false,
...(refusal.payload !== undefined ? { payload: refusal.payload } : {}),
...(refusal.portalUrl !== undefined ? { portal_url: refusal.portalUrl } : {}),
...(refusal.retryAfter !== undefined ? { retry_after: refusal.retryAfter } : {})
} as BillingChargeStatusResponse
}
function refusalFromStatus(error: string, status: BillingChargeStatusResponse): BillingRefusal {
return {
kind: error,
message: status.message || error,
payload: status.payload,
portalUrl: status.portal_url ?? undefined,
retryAfter: status.retry_after ?? undefined
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function useChargeFlow() {
const api = useBillingApi()
const queryClient = useQueryClient()
const [phase, setPhase] = useState<ChargeFlowPhase>('idle')
const [outcome, setOutcome] = useState<ChargeFlowOutcome | null>(null)
const phaseRef = useRef<ChargeFlowPhase>('idle')
const retryIntentRef = useRef<PendingChargeIntent | null>(null)
const setPhaseState = useCallback((next: ChargeFlowPhase) => {
phaseRef.current = next
setPhase(next)
}, [])
const reset = useCallback(() => {
retryIntentRef.current = null
setOutcome(null)
setPhaseState('idle')
}, [setPhaseState])
const start = useCallback(
async (amountUsd: string) => {
if (phaseRef.current === 'charging' || phaseRef.current === 'polling') {
return
}
const retryIntent = retryIntentRef.current
const idempotencyKey = retryIntent?.amountUsd === amountUsd ? retryIntent.idempotencyKey : undefined
setOutcome(null)
setPhaseState('charging')
const chargeResult = await api.charge(amountUsd, idempotencyKey)
if (!chargeResult.ok) {
const resolved = resolveRefusal(chargeResult.refusal)
const action =
resolved.action.type === 'portal'
? ({ type: 'portal', url: resolved.action.url } as const)
: resolved.action.type === 'retry'
? ({ type: 'retry' } as const)
: resolved.action.type === 'step_up'
? ({ type: 'step_up' } as const)
: undefined
retryIntentRef.current = shouldReuseIdempotencyKey(chargeResult.refusal)
? { amountUsd, idempotencyKey: chargeResult.idempotencyKey }
: null
setOutcome({
action,
kind: 'failure',
message: resolved.message,
retryFreshKey: false,
title: resolved.title
})
setPhaseState('done')
return
}
retryIntentRef.current = null
const chargeId = chargeResult.data.charge_id
if (!chargeId) {
setOutcome({
kind: 'failure',
message: 'The billing service accepted the request but did not return a charge id.',
retryFreshKey: true,
title: 'Charge could not be tracked'
})
setPhaseState('done')
return
}
setPhaseState('polling')
const pollOutcome = await pollChargeSettlement(api, chargeId, {
portalUrl: chargeResult.data.portal_url
})
setOutcome(pollOutcome)
setPhaseState('done')
if (pollOutcome.kind === 'success') {
void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
}
},
[api, queryClient, setPhaseState]
)
return { outcome, phase, reset, start }
}
function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean {
return retryableSendKinds.has(refusal.kind)
}
function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome {
return {
kind: 'ambiguous',
message: 'Charge may still settle. Check the portal before retrying.',
portalUrl: portalUrl ?? undefined,
title: 'Still processing after 5 minutes'
}
}
function renderChargeFailed(reason?: null | string): string {
switch ((reason || '').trim()) {
case 'authentication_required':
return 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.'
case 'payment_method_expired':
return 'Your card has expired. Update it on the portal.'
case 'card_declined':
return 'Your card was declined. Try another card on the portal.'
default:
return `The charge didn't go through (${reason || 'processing_error'}).`
}
}
@@ -0,0 +1,132 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { createElement, type PropsWithChildren } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
stepUp: vi.fn()
}))
const gatewayMock = vi.hoisted(() => {
const handlers = new Map<string, Set<(event: unknown) => void>>()
const gateway = {
on: vi.fn((eventName: string, handler: (event: unknown) => void) => {
let set = handlers.get(eventName)
if (!set) {
set = new Set()
handlers.set(eventName, set)
}
set.add(handler)
return () => set?.delete(handler)
})
}
return {
count: (eventName: string) => handlers.get(eventName)?.size ?? 0,
emit: (eventName: string, event: unknown) => {
handlers.get(eventName)?.forEach(handler => handler(event))
},
gateway,
reset: () => {
handlers.clear()
gateway.on.mockClear()
}
}
})
vi.mock('@/store/gateway', async () => {
const { atom } = (await vi.importActual('nanostores')) as { atom: (value: unknown) => unknown }
return {
$gateway: atom(gatewayMock.gateway)
}
})
vi.mock('./api', () => ({
useBillingApi: () => ({
stepUp: apiMocks.stepUp
})
}))
import { useStepUpFlow } from './use-step-up'
function createWrapper(client: QueryClient) {
return function wrapper({ children }: PropsWithChildren) {
return createElement(QueryClientProvider, { client }, children)
}
}
beforeEach(() => {
apiMocks.stepUp.mockReset()
gatewayMock.reset()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('useStepUpFlow', () => {
it('subscribes for verification, opens the verification URL, cleans up, and invalidates on completion', async () => {
let resolveStepUp: (value: unknown) => void = () => {}
const stepUpPromise = new Promise(resolve => {
resolveStepUp = resolve
})
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.stepUp.mockReturnValue(stepUpPromise)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: vi.fn()
}
})
const { result, unmount } = renderHook(() => useStepUpFlow(), { wrapper: createWrapper(client) })
act(() => {
void result.current.start()
})
expect(result.current.phase).toBe('waiting')
expect(gatewayMock.count('billing.step_up.verification')).toBe(1)
act(() => {
gatewayMock.emit('billing.step_up.verification', {
payload: {
user_code: 'ABCD-1234',
verification_url: 'https://portal.nousresearch.com/device'
},
type: 'billing.step_up.verification'
})
})
expect(result.current.phase).toBe('verifying')
expect(result.current.verification).toEqual({
code: 'ABCD-1234',
url: 'https://portal.nousresearch.com/device'
})
result.current.openVerification()
expect(window.hermesDesktop?.openExternal).toHaveBeenCalledWith('https://portal.nousresearch.com/device')
await act(async () => {
resolveStepUp({ data: { granted: true, ok: true }, ok: true })
await stepUpPromise
})
await waitFor(() => {
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] })
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] })
})
unmount()
expect(gatewayMock.count('billing.step_up.verification')).toBe(0)
})
})
@@ -0,0 +1,143 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { $gateway } from '@/store/gateway'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
export type StepUpPhase = 'idle' | 'verifying' | 'waiting'
export interface StepUpVerification {
code: string | null
url: string
}
export interface StepUpMessage {
kind: 'error' | 'success'
text: string
title: string
}
interface StepUpVerificationPayload {
user_code?: unknown
verification_url?: unknown
}
export function useStepUpFlow() {
const api = useBillingApi()
const gateway = useStore($gateway)
const queryClient = useQueryClient()
const offRef = useRef<(() => void) | null>(null)
const runningRef = useRef(false)
const runIdRef = useRef(0)
const [message, setMessage] = useState<StepUpMessage | null>(null)
const [phase, setPhase] = useState<StepUpPhase>('idle')
const [verification, setVerification] = useState<StepUpVerification | null>(null)
const unsubscribe = useCallback(() => {
offRef.current?.()
offRef.current = null
}, [])
const dismiss = useCallback(() => {
runIdRef.current += 1
runningRef.current = false
unsubscribe()
setMessage(null)
setPhase('idle')
setVerification(null)
}, [unsubscribe])
useEffect(
() => () => {
runIdRef.current += 1
runningRef.current = false
unsubscribe()
},
[unsubscribe]
)
const openVerification = useCallback(() => {
if (!verification?.url) {
return
}
void window.hermesDesktop?.openExternal?.(verification.url)
}, [verification?.url])
const start = useCallback(async () => {
if (runningRef.current) {
return
}
runningRef.current = true
const runId = runIdRef.current + 1
runIdRef.current = runId
unsubscribe()
setMessage(null)
setVerification(null)
setPhase('waiting')
offRef.current =
gateway?.on<StepUpVerificationPayload>('billing.step_up.verification', event => {
const payload = event.payload
const url = typeof payload?.verification_url === 'string' ? payload.verification_url : null
if (!url) {
return
}
setVerification({
code: typeof payload?.user_code === 'string' ? payload.user_code : null,
url
})
setPhase('verifying')
}) ?? null
const result = await api.stepUp()
if (runIdRef.current !== runId) {
return
}
runningRef.current = false
unsubscribe()
if (!result.ok) {
const resolved = resolveRefusal(result.refusal)
setMessage({
kind: 'error',
text: resolved.message,
title: resolved.title
})
return
}
if (!result.data.granted) {
setMessage({
kind: 'error',
text: 'Verification finished without allowing Remote Spending for this terminal.',
title: 'Verification was not approved'
})
return
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }),
queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] })
])
setMessage({
kind: 'success',
text: 'Remote Spending is allowed for this terminal.',
title: 'Verification complete'
})
}, [api, gateway, queryClient, unsubscribe])
return { dismiss, message, openVerification, phase, start, verification }
}
@@ -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')
})
})
@@ -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 }
}