Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user