diff --git a/apps/desktop/src/app/settings/billing/api.test.ts b/apps/desktop/src/app/settings/billing/api.test.ts new file mode 100644 index 000000000000..02f359c35a58 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/api.test.ts @@ -0,0 +1,172 @@ +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('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' + }) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/api.ts b/apps/desktop/src/app/settings/billing/api.ts new file mode 100644 index 000000000000..6177db8da1b6 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/api.ts @@ -0,0 +1,168 @@ +import { useMemo } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' + +import type { + BillingChargeResponse, + BillingChargeStatusResponse, + BillingErrorPayload, + BillingMutationResponse, + BillingRefusalCode, + BillingStateResponse, + 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 = { data: T; ok: true } | { ok: false; refusal: BillingRefusal } + +export type BillingChargeResult = BillingResult & { idempotencyKey: string } + +export interface UpdateAutoReloadInput { + enabled: boolean + reload_to_usd?: string + threshold_usd?: string +} + +export type BillingRequestGateway = ( + method: string, + params?: Record, + timeoutMs?: number, + signal?: AbortSignal +) => Promise + +export interface BillingApi { + charge: (amountUsd: string, idempotencyKey?: string) => Promise + chargeStatus: (chargeId: string) => Promise> + fetchBillingState: () => Promise> + fetchSubscriptionState: () => Promise> + stepUp: (sessionId?: string) => Promise> + updateAutoReload: (input: UpdateAutoReloadInput) => Promise> +} + +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 => 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): 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 = (response: T): BillingResult => { + if (isRecord(response) && response.ok === false) { + return { ok: false, refusal: normalizeRefusal(response) } + } + + return { data: response, ok: true } +} + +const callBilling = async ( + requestGateway: BillingRequestGateway, + method: string, + params: Record = {} +): Promise> => { + try { + return normalizeRpcResult(await requestGateway(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(requestGateway, 'billing.charge', { + amount_usd: amountUsd, + idempotency_key: idempotencyKey + }) + + return { ...result, idempotencyKey } + }, + chargeStatus: chargeId => + callBilling(requestGateway, 'billing.charge_status', { charge_id: chargeId }), + fetchBillingState: () => callBilling(requestGateway, 'billing.state'), + fetchSubscriptionState: () => callBilling(requestGateway, 'subscription.state'), + stepUp: sessionId => + callBilling(requestGateway, 'billing.step_up', { + ...(sessionId !== undefined ? { session_id: sessionId } : {}) + }), + updateAutoReload: input => + callBilling(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 } : {}) + }) +}) + +export function useBillingApi(): BillingApi { + const { requestGateway } = useGatewayRequest() + + return useMemo(() => createBillingApi(requestGateway), [requestGateway]) +} diff --git a/apps/desktop/src/app/settings/billing/dev-fixtures.ts b/apps/desktop/src/app/settings/billing/dev-fixtures.ts new file mode 100644 index 000000000000..8f69cf687a1e --- /dev/null +++ b/apps/desktop/src/app/settings/billing/dev-fixtures.ts @@ -0,0 +1,315 @@ +import type { BillingResult } from './api' +import type { BillingStateResponse, SubscriptionStateResponse } from './types' + +const current = ( + overrides: Partial> = {} +): NonNullable => ({ + 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 + +const okBilling = (data: BillingStateResponse): BillingResult => ({ data, ok: true }) + +const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ + 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' }), + '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 + subscription: BillingResult + } +> + +export type BillingDevFixtureName = keyof typeof billingDevFixtures diff --git a/apps/desktop/src/app/settings/billing/errors.test.ts b/apps/desktop/src/app/settings/billing/errors.test.ts new file mode 100644 index 000000000000..f478f8ca5266 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/errors.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import type { KnownBillingRefusalCode } from '@hermes/shared/billing' + +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' + }) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/errors.ts b/apps/desktop/src/app/settings/billing/errors.ts new file mode 100644 index 000000000000..90207d3d37cb --- /dev/null +++ b/apps/desktop/src/app/settings/billing/errors.ts @@ -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 terminal billing enabled. Start a top-up to enable it, then retry.', + title: 'Terminal billing needs approval' + } + case 'remote_spending_revoked': { + const who = + refusal.actor === 'admin' + ? 'An admin turned off terminal billing for this terminal.' + : 'You turned off terminal billing for this terminal.' + + return { + action: portalAction(refusal.portalUrl), + message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`, + title: 'Terminal billing was turned off' + } + } + + 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: 'Terminal billing is off for this account — an admin must enable it on the portal.', + title: 'Terminal billing 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' + } + } +} diff --git a/apps/desktop/src/app/settings/billing/fixtures.test-util.ts b/apps/desktop/src/app/settings/billing/fixtures.test-util.ts new file mode 100644 index 000000000000..fb72e21b1a68 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/fixtures.test-util.ts @@ -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 => ({ data, ok: true }) + +export const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ + data, + ok: true +}) + +export const endpointUnavailableBilling = { + ok: false, + refusal: { + kind: 'endpoint_unavailable', + message: 'Billing endpoint returned a non-JSON response.' + } +} satisfies BillingResult + +export const endpointUnavailableSubscription = { + ok: false, + refusal: { + kind: 'endpoint_unavailable', + message: 'Subscription endpoint is not available.' + } +} satisfies BillingResult diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx new file mode 100644 index 000000000000..27b702d99d34 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -0,0 +1,380 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + billingDevFixtures, + loggedOutBillingState, + loggedOutSubscriptionState, + okBilling, + okSubscription, + postTrainBillingState, + postTrainSubscriptionState, + todayBillingState, + todaySubscriptionState +} from './fixtures.test-util' +import { formatUsageUpdatedAgo } from './use-billing-state' + +import { BillingSettings } from './index' + +const apiMocks = vi.hoisted(() => ({ + charge: vi.fn(), + chargeStatus: vi.fn(), + fetchBillingState: vi.fn(), + fetchSubscriptionState: vi.fn(), + openExternal: vi.fn(), + stepUp: vi.fn(), + updateAutoReload: vi.fn() +})) + +vi.mock('./api', () => ({ + useBillingApi: () => ({ + charge: apiMocks.charge, + chargeStatus: apiMocks.chargeStatus, + fetchBillingState: apiMocks.fetchBillingState, + fetchSubscriptionState: apiMocks.fetchSubscriptionState, + stepUp: apiMocks.stepUp, + updateAutoReload: apiMocks.updateAutoReload + }) +})) + +function renderBilling() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + ) + + 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('Terminal billing is off for this account — an admin must enable it on the portal.') + ).toBeTruthy() + expect(screen.queryByRole('button', { name: '$100' })).toBeNull() + expect(screen.getByText('Refill $10 when 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() + + expect(await screen.findByText('No card on file')).toBeTruthy() + 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 $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('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' })) + + await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false })) + }) + + 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('Terminal billing needs approval:')).toBeTruthy() + expect( + screen.getByText('This needs terminal billing enabled. Start a top-up to enable 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('$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' }) + + expect(subscriptionTrack.classList.contains('dither')).toBe(true) + expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true) + expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true) + }) + + 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) + expect(subscriptionTrack.classList.contains('dither')).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(true) + expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true) + }) + + it('refreshes both billing queries from the usage refresh button', async () => { + renderBilling() + + await screen.findByText('$120 of $220 left') + expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1) + expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2)) + expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2) + }) + + it('disables the usage refresh button while either query is fetching', async () => { + let settleBilling: (value: unknown) => void = () => {} + + let settleSubscription: (value: unknown) => void = () => {} + + apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce( + new Promise(resolve => { + settleBilling = resolve + }) + ) + apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce( + new Promise(resolve => { + settleSubscription = resolve + }) + ) + + renderBilling() + + const refresh = await screen.findByRole('button', { name: 'Refresh' }) + + fireEvent.click(refresh) + + await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true)) + + settleBilling(okBilling(todayBillingState)) + settleSubscription(okSubscription(todaySubscriptionState)) + + await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false)) + }) +}) + +describe('formatUsageUpdatedAgo', () => { + it('formats sub-second and current timestamps as just now', () => { + expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now') + expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now') + }) + + it('formats seconds below a minute', () => { + expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago') + }) + + it('rounds elapsed time to whole minutes from 61 seconds', () => { + expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago') + }) + + it('formats one hour and later as hours', () => { + expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago') + }) +}) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx new file mode 100644 index 000000000000..94ffda3b7c64 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -0,0 +1,961 @@ +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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tip } from '@/components/ui/tooltip' +import { BarChart3, ExternalLink, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives' + +import type { BillingRefusal } from './api' +import { useBillingApi } from './api' +import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures' +import { resolveRefusal } from './errors' +import type { BillingAutoReload, BillingStateResponse } from './types' +import { + type BillingAccountRowView, + type BillingNoticeView, + type BillingUsageRowView, + deriveBillingView, + EMPTY_BILLING_VALUE, + formatUsageUpdatedAgo, + useBillingState, + useSubscriptionState +} from './use-billing-state' +import { useChargeFlow } from './use-charge-poller' +import { useStepUpFlow } from './use-step-up' + +const FEATURE_BILLING_INVOICES = false + +const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV + ? (Object.keys(billingDevFixtures) as BillingDevFixtureName[]) + : [] + +type BillingFixtureSelection = 'live' | BillingDevFixtureName + +function openExternal(url?: string) { + if (!url) { + return + } + + void window.hermesDesktop?.openExternal?.(url) +} + +function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +function NoticeCard({ notice }: { notice: BillingNoticeView }) { + return ( +
+
{notice.title}
+
+ {notice.message} +
+ {notice.action && ( + + )} +
+ ) +} + +function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) { + return ( +
+ {row.value && ( + + {row.value} + + )} + {row.pill && {row.pill.label}} + {row.secondaryPill && {row.secondaryPill}} + {row.chips?.map(chip => ( + + ))} + {row.action && ( + + )} +
+ ) +} + +function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) { + if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) { + return + } + + if (row.id === 'auto_reload' && billing?.auto_reload) { + return + } + + return ( + } + below={ + row.caption ? ( +
+ {row.caption} +
+ ) : undefined + } + description={row.description} + key={row.id} + title={row.title} + /> + ) +} + +function AutoReloadRow({ + autoReload, + bounds, + row +}: { + autoReload: BillingAutoReload + bounds: Pick + row: BillingAccountRowView +}) { + const api = useBillingApi() + const queryClient = useQueryClient() + const [confirmDisable, setConfirmDisable] = useState(false) + const [editing, setEditing] = useState(false) + const [message, setMessage] = useState(null) + const [refusal, setRefusal] = useState(null) + + const [reloadTo, setReloadTo] = useState( + initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display) + ) + + const [saving, setSaving] = useState(false) + + const [threshold, setThreshold] = useState( + initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display) + ) + + const validation = validateAutoReloadInputs(threshold, reloadTo, bounds) + const busy = saving + const maxBound = bounds.max_usd ?? undefined + const minBound = bounds.min_usd ?? undefined + + const resetFeedback = () => { + setConfirmDisable(false) + setMessage(null) + setRefusal(null) + } + + 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) + + const result = await api.updateAutoReload({ enabled: false }) + + 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) + } + + const below = editing ? ( +
+
+ + +
+ {validation.error && ( +
{validation.error}
+ )} +
+ + + +
+ {confirmDisable && ( +
+ Turn off auto-refill? + + +
+ )} + + {message && {message.text}} +
+ ) : ( + <> + {row.caption ? ( +
+ {row.caption} +
+ ) : null} + + {message && {message.text}} + + ) + + return ( + { + resetFeedback() + setEditing(true) + } + } + row={row} + /> + } + below={below} + 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 ( + + {presets.map(preset => ( + + ))} + setAmount(clampedAmount)} + onChange={event => { + flow.reset() + setAmount(event.target.value) + }} + placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'} + step="0.01" + type="number" + value={amount} + /> + + + } + below={ + { + 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['outcome'] +}) { + const stepUp = useStepUpFlow() + + if (busy) { + return ( +
+ Processing… checking settlement +
+ ) + } + + if (!outcome) { + return null + } + + if (outcome.kind === 'success') { + return ( +
+ {formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing. +
+ ) + } + + if (outcome.kind === 'ambiguous') { + return ( +
+ + {outcome.title}: {outcome.message} + + {outcome.portalUrl && ( + + )} +
+ ) + } + + const portalUrl = outcome.action?.type === 'portal' ? outcome.action.url : undefined + + return ( +
+ + {outcome.title}: {outcome.message} + + {outcome.action?.type === 'retry' && ( + + )} + {outcome.action?.type === 'step_up' && } + {portalUrl && ( + + )} +
+ ) +} + +function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) { + const stepUp = useStepUpFlow() + + if (!refusal) { + return null + } + + const resolved = resolveRefusal(refusal) + const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined + + return ( +
+ + {resolved.title}: {resolved.message} + + {resolved.action.type === 'step_up' && } + {portalUrl && ( + + )} +
+ ) +} + +function StepUpInlineAction({ flow }: { flow: ReturnType }) { + if (flow.verification) { + return ( + + {flow.verification.code} + + + ) + } + + if (flow.message) { + return ( + + + {flow.message.title}: {flow.message.text} + + + + ) + } + + if (flow.phase === 'waiting') { + return Waiting for verification link… + } + + return ( + + ) +} + +function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) { + return ( +
+ {children} +
+ ) +} + +function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) { + const resolvedBar = bar ?? { + label: `${fallbackLabel} usage`, + state: 'neutral', + tone: 'topup', + value: 0 + } + + const width = Math.round(resolvedBar.value * 100) + const isEmpty = resolvedBar.value === 0 + const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0 + + return ( +
+ {showDangerNub &&
} +
0 ? 4 : undefined, + width: `${width}%` + }} + /> +
+ ) +} + +function UsageRow({ row }: { row: BillingUsageRowView }) { + return ( +
+
+
+
+ {row.title} +
+
+ {row.caption} +
+
+
+ +
+
+ {row.value} +
+
+
+ ) +} + +function UsageRefreshRow({ + fixtureName, + isFetching, + onRefresh, + updatedAt +}: { + fixtureName?: BillingFixtureSelection + isFetching: boolean + onRefresh: () => void + updatedAt: number +}) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const interval = window.setInterval(() => setNow(Date.now()), 30_000) + + return () => window.clearInterval(interval) + }, []) + + if (fixtureName && fixtureName !== 'live') { + return ( +
+ fixture: {fixtureName} +
+ ) + } + + return ( +
+ Updated {formatUsageUpdatedAgo(updatedAt, now)} + + + +
+ ) +} + +function BillingFixtureSelect({ + onValueChange, + value +}: { + onValueChange: (value: BillingFixtureSelection) => void + value: BillingFixtureSelection +}) { + return ( + + ) +} + +function BillingHeader({ + fixtureName, + onFixtureChange +}: { + fixtureName?: BillingFixtureSelection + onFixtureChange?: (value: BillingFixtureSelection) => void +}) { + return ( +
+
+ + Billing +
+ {import.meta.env.DEV && fixtureName && onFixtureChange ? ( + + ) : null} +
+ ) +} + +function BillingSettingsContent({ + fixtureName, + onFixtureChange +}: { + fixtureName?: BillingFixtureSelection + onFixtureChange?: (value: BillingFixtureSelection) => void +}) { + const fixture = + import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined + + const billingState = useBillingState(!fixture) + const subscriptionState = useSubscriptionState(!fixture) + const billingResult = fixture?.billing ?? billingState.data + const subscriptionResult = fixture?.subscription ?? subscriptionState.data + const view = deriveBillingView(billingResult, subscriptionResult) + const billing = billingResult?.ok ? billingResult.data : undefined + const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt) + const usageIsFetching = billingState.isFetching || subscriptionState.isFetching + + const refreshUsage = () => { + void Promise.all([billingState.refetch(), subscriptionState.refetch()]) + } + + return ( + + + +
+
+ {view.summary.map(item => ( + + ))} +
+
+ + {view.notice && } + + {view.accountRows.length > 0 && ( + <> + + {view.accountRows.map(row => ( + + ))} + + )} + + {view.usageRows.length > 0 && ( + <> + +
+ {view.usageRows.map(row => ( + + ))} + +
+ + )} + + { + // no endpoint yet — NAS capability-board gap + FEATURE_BILLING_INVOICES ? : null + } +
+ ) +} + +function BillingSettingsWithDevFixtures() { + const [fixtureName, setFixtureName] = useState('live') + + return +} + +export function BillingSettings() { + if (import.meta.env.DEV) { + return + } + + return +} + +function clampAmount(raw: string, billing: Pick): string { + const amount = parseAmount(raw) + + if (amount == null) { + return '' + } + + const min = parseAmount(billing.min_usd) + const max = parseAmount(billing.max_usd) + const clampedMin = min == null ? amount : Math.max(min, amount) + const clamped = max == null ? clampedMin : Math.min(max, clampedMin) + + return formatAmountForRequest(clamped) +} + +function parseAmount(value?: null | number | string): null | number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + + if (typeof value !== 'string') { + return null + } + + const parsed = Number(value.replace(/[$,\s]/g, '')) + + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function formatAmountForRequest(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +function oldestUpdatedAt(...timestamps: number[]): number { + const populated = timestamps.filter(timestamp => timestamp > 0) + + return populated.length > 0 ? Math.min(...populated) : Date.now() +} + +function initialAutoReloadAmount(...candidates: Array): string { + for (const candidate of candidates) { + const amount = parseAmount(candidate) + + if (amount != null) { + return formatAmountForRequest(amount) + } + } + + return '' +} + +function validateAutoReloadInputs( + thresholdRaw: string, + reloadToRaw: string, + bounds: Pick +): { error?: string; values?: { reloadTo: string; threshold: string } } { + const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds) + + if (threshold.error || threshold.amount == null) { + return { error: threshold.error } + } + + const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds) + + if (reloadTo.error || reloadTo.amount == null) { + return { error: reloadTo.error } + } + + if (reloadTo.amount <= threshold.amount) { + return { error: 'Reload-to amount must be greater than the threshold.' } + } + + return { + values: { + reloadTo: formatAmountForRequest(reloadTo.amount), + threshold: formatAmountForRequest(threshold.amount) + } + } +} + +function validateBillingAmount( + label: string, + raw: string, + bounds: Pick +): { amount?: number; error?: string } { + const cleaned = raw.trim().replace(/^\$/, '').trim() + + if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) { + return { error: `${label}: enter a dollar amount with at most 2 decimal places.` } + } + + const amount = Number(cleaned) + + if (!(amount > 0)) { + return { error: `${label}: amount must be greater than $0.` } + } + + const min = parseAmount(bounds.min_usd) + + if (min != null && amount < min) { + return { error: `${label}: minimum is ${formatMoney(min)}.` } + } + + const max = parseAmount(bounds.max_usd) + + if (max != null && amount > max) { + return { error: `${label}: maximum is ${formatMoney(max)}.` } + } + + return { amount } +} + +function formatMoney(value?: null | number | string): string { + const amount = parseAmount(value) + + if (amount == null) { + return EMPTY_BILLING_VALUE + } + + return new Intl.NumberFormat(undefined, { + currency: 'USD', + maximumFractionDigits: amount % 1 === 0 ? 0 : 2, + minimumFractionDigits: amount % 1 === 0 ? 0 : 2, + style: 'currency' + }).format(amount) +} diff --git a/apps/desktop/src/app/settings/billing/types.test.ts b/apps/desktop/src/app/settings/billing/types.test.ts new file mode 100644 index 000000000000..a189309a4615 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/types.test.ts @@ -0,0 +1,120 @@ +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) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/types.ts b/apps/desktop/src/app/settings/billing/types.ts new file mode 100644 index 000000000000..eee9718ae3e9 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/types.ts @@ -0,0 +1,33 @@ +import type { + BillingAutoReload, + BillingCardInfo, + BillingChargeResponse, + BillingChargeStatusResponse, + BillingErrorPayload, + BillingMonthlyCap, + BillingMutationResponse, + BillingRefusalCode, + BillingStateResponse, + ChargeFailureReason, + SubscriptionStateResponse, + SubscriptionTierOption, + UsageBarData, + UsageModelData +} from '@hermes/shared/billing' + +export type { + BillingAutoReload, + BillingCardInfo, + BillingChargeResponse, + BillingChargeStatusResponse, + BillingErrorPayload, + BillingMonthlyCap, + BillingMutationResponse, + BillingRefusalCode, + BillingStateResponse, + ChargeFailureReason, + SubscriptionStateResponse, + SubscriptionTierOption, + UsageBarData, + UsageModelData +} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts new file mode 100644 index 000000000000..f7f751e3ffe5 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -0,0 +1,302 @@ +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 } 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' }) + const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') + + expect(buyCredits?.description).toBe( + 'Terminal billing is off for this account — an admin must enable it on the portal.' + ) + expect(buyCredits?.chips).toBeUndefined() + expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({ + action: { label: 'Manage' }, + caption: 'Refill $10 when balance falls below $5', + 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.accountRows.find(row => row.id === 'payment_method')?.value).toBe('Visa •••• 4242 - subscription card') + expect(view.accountRows.find(row => row.id === 'buy_credits')?.chips?.map(chip => chip.label)).toEqual([ + '$25', + '$50', + '$100' + ]) + expect(view.accountRows.find(row => row.id === 'subscription')?.action?.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) + ) + const autoReload = view.accountRows.find(row => row.id === 'auto_reload') + + expect(autoReload?.caption).toContain('Mastercard ••4444') + expect(autoReload?.caption).toContain('reconcile') + expect(autoReload?.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) + ) + const autoReload = view.accountRows.find(row => row.id === 'auto_reload') + + expect(autoReload?.caption).toContain('a different card') + expect(autoReload?.caption).not.toContain('null') + expect(autoReload?.action?.url).toBe('https://portal.nousresearch.com/billing') + }) + + 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.accountRows.find(row => row.id === 'buy_credits') + + expect(buyCredits).toMatchObject({ + action: { disabled: true, label: 'Buy' }, + description: + '💳 No saved card for terminal charges yet. Set one up on the portal ' + + "(one-time credit buys don't save a reusable card)." + }) + expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true]) + }) + + 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.accountRows).toEqual([]) + 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.accountRows).toEqual([]) + }) + + it('keeps subscription unavailable as a row-level degradation when billing.state succeeds', () => { + const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription) + const subscription = view.accountRows.find(row => row.id === 'subscription') + + expect(view.status).toBe('normal') + expect(subscription).toMatchObject({ + caption: 'Subscription details are unavailable; opening the portal is still available.', + value: 'Ultra' + }) + }) + + 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 full ok bar when credits remain', () => { + const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)) + + expect(view.usageRows.find(row => row.id === 'topup_credits')).toMatchObject({ + bar: { + state: 'ok', + tone: 'topup', + value: 1 + }, + value: '$75' + }) + }) + + it('renders zero top-up balance as an empty neutral bar', () => { + const view = deriveBillingView( + okBilling({ + ...todayBillingState, + balance_display: '$0', + balance_usd: '0', + usage: { + ...todayBillingState.usage, + topup_remaining_display: '$0' + } + }), + undefined + ) + + expect(view.usageRows.find(row => row.id === 'topup_credits')).toMatchObject({ + bar: { + state: 'neutral', + tone: 'topup', + value: 0 + }, + value: '$0' + }) + }) +}) + +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') + }) +}) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts new file mode 100644 index 000000000000..33ee75e77fb9 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -0,0 +1,584 @@ +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, 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' + +const BILLING_QUERY_OPTIONS = { + refetchOnWindowFocus: true, + retry: false, + staleTime: 30_000 +} 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 +} + +export interface BillingRowActionView { + disabled?: boolean + label: string + url?: string +} + +export interface BillingChipView { + disabled: boolean + label: string +} + +export interface BillingAccountRowView { + action?: BillingRowActionView + caption?: string + chips?: BillingChipView[] + description: string + id: 'auto_reload' | 'buy_credits' | 'payment_method' | 'subscription' + pill?: { + label: string + tone: 'muted' | 'primary' + } + secondaryPill?: string + title: string + value?: string +} + +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 { + accountRows: BillingAccountRowView[] + notice?: BillingNoticeView + status: 'loading' | 'logged_out' | 'normal' | 'refusal' + summary: BillingSummaryItemView[] + 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, + subscriptionResult?: BillingResult +): BillingView { + if (!stateResult) { + return { + accountRows: [], + status: 'loading', + summary: emptySummary(), + usageRows: [] + } + } + + if (!stateResult.ok) { + return { + accountRows: [], + notice: refusalNotice(stateResult.refusal), + status: 'refusal', + summary: emptySummary(), + usageRows: [] + } + } + + const billing = stateResult.data + const subscription = subscriptionResult?.ok ? subscriptionResult.data : null + + if (!billing.logged_in || subscription?.logged_in === false) { + return { + accountRows: [], + 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(), + usageRows: [] + } + } + + return { + accountRows: deriveAccountRows(billing, subscription, subscriptionResult), + 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 + } + ], + usageRows: deriveUsageRows(billing, subscription) + } +} + +export function buildManageSubscriptionUrl( + subscription?: null | Pick, + fallbackPortalUrl?: null | string +): string { + const portalUrls = [subscription?.portal_url, fallbackPortalUrl].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) + } + + 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) +} + +export function formatUsageUpdatedAgo(updatedAt: number, now: number): string { + const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)) + + if (elapsedSeconds < 1) { + return 'just now' + } + + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s ago` + } + + const elapsedMinutes = Math.floor(elapsedSeconds / 60) + + if (elapsedMinutes < 60) { + return `${elapsedMinutes}m ago` + } + + return `${Math.floor(elapsedMinutes / 60)}h ago` +} + +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 + } +} + +function deriveAccountRows( + billing: BillingStateResponse, + subscription: null | SubscriptionStateResponse, + subscriptionResult?: BillingResult +): BillingAccountRowView[] { + return [ + paymentMethodRow(billing), + subscriptionRow(billing, subscription, subscriptionResult), + buyCreditsRow(billing), + autoReloadRow(billing) + ] +} + +function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView { + const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL + const card = billing.card + + if (!card) { + return { + action: { label: 'Update ↗', url: portalUrl }, + description: 'Add a payment method on the portal before buying top-up credits.', + id: 'payment_method', + title: 'Payment method', + value: 'No card on file' + } + } + + 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 subscriptionRow( + billing: BillingStateResponse, + subscription: null | SubscriptionStateResponse, + subscriptionResult?: BillingResult +): BillingAccountRowView { + const manageUrl = buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url) + const current = subscription?.current + const fallbackPlan = billing.usage?.plan_name ?? EMPTY_BILLING_VALUE + const value = current?.tier_name ?? fallbackPlan + const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) + const unavailable = subscriptionResult && !subscriptionResult.ok + + return { + action: { label: 'Adjust plan ↗', url: manageUrl }, + caption: unavailable + ? 'Subscription details are unavailable; opening the portal is still available.' + : `Renews ${renewal}`, + description: 'Review your plan and change it from the billing portal.', + id: 'subscription', + secondaryPill: 'opens portal', + title: 'Subscription', + value + } +} + +function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView { + if (!billing.card) { + return { + action: { disabled: true, label: 'Buy' }, + chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })), + description: resolveRefusal({ + kind: 'no_payment_method', + message: '', + portalUrl: billing.portal_url ?? undefined + }).message, + id: 'buy_credits', + title: 'Buy credits' + } + } + + const disabledReason = buyCreditsDisabledReason(billing) + + if (disabledReason) { + return { + description: disabledReason, + id: 'buy_credits', + title: 'Buy credits' + } + } + + return { + action: { disabled: true, label: 'Buy' }, + chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })), + description: 'Add top-up credits for agent runs outside your plan.', + id: 'buy_credits', + title: 'Buy credits' + } +} + +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: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' }, + title: 'Auto-refill' + } + } + + if (!autoReload.enabled) { + return { + caption: 'Turn on auto-refill from the portal', + description: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: 'Off', tone: 'muted' }, + title: 'Auto-refill' + } + } + + 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: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: 'Enabled', tone: 'primary' }, + title: 'Auto-refill' + } + } + + return { + action: { label: 'Manage' }, + caption: `Refill ${autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)} when balance falls below ${ + autoReload.threshold_display || formatMoney(autoReload.threshold_usd) + }`, + description: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: 'Enabled', tone: 'primary' }, + title: 'Auto-refill' + } +} + +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) + const topupRemaining = topupCreditsAmount(billing, usage) + + rows.push({ + bar: + topupRemaining != null + ? { + label: 'Top-up credits remaining', + state: topupRemaining > 0 ? 'ok' : 'neutral', + tone: 'topup', + value: topupRemaining > 0 ? 1 : 0 + } + : undefined, + 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 terminal billing spend', + 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 currentTier = subscription?.tiers.find(t => t.is_current || t.tier_id === current?.tier_id) + const price = currentTier?.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 topupCreditsAmount(billing: BillingStateResponse, usage?: UsageModelData): null | number { + return ( + parseAmount(usage?.topup_bar?.remaining_display) ?? + parseAmount(usage?.topup_remaining_display) ?? + parseAmount(billing.balance_usd) ?? + parseAmount(billing.balance_display) + ) +} + +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 = { + 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)) +} diff --git a/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts b/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts new file mode 100644 index 000000000000..82f6df182fee --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts @@ -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 = {}): BillingResult => ({ + data: { + ok: true, + status: 'pending', + ...overrides + }, + ok: true +}) + +const refusal = ( + kind: string, + overrides: Partial, { ok: false }>['refusal']> = {} +): BillingResult => ({ + 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') + }) +}) diff --git a/apps/desktop/src/app/settings/billing/use-charge-poller.ts b/apps/desktop/src/app/settings/billing/use-charge-poller.ts new file mode 100644 index 000000000000..8727365a4315 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-charge-poller.ts @@ -0,0 +1,298 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useRef, useState } from 'react' + +import { refusalPolicy } from '@hermes/shared/billing-policy' +import { + driveChargeSettlement, + SETTLEMENT_POLL_CAP_MS, + SETTLEMENT_POLL_INTERVAL_MS +} from '@hermes/shared/charge-settlement' + +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 +} + +export interface ChargePollOptions extends ChargePollClock { + portalUrl?: null | string +} + +interface PendingChargeIntent { + amountUsd: string + idempotencyKey: string +} + +const defaultSleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +const retryableSendKinds = new Set([ + 'endpoint_unavailable', + 'rate_limited', + 'temporarily_unavailable', + 'timeout', + 'transport' +]) + +export async function pollChargeSettlement( + api: Pick, + chargeId: string, + opts: ChargePollOptions = {} +): Promise { + 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 { + return typeof value === 'object' && value !== null +} + +export function useChargeFlow() { + const api = useBillingApi() + const queryClient = useQueryClient() + const [phase, setPhase] = useState('idle') + const [outcome, setOutcome] = useState(null) + const phaseRef = useRef('idle') + const retryIntentRef = useRef(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'}).` + } +} diff --git a/apps/desktop/src/app/settings/billing/use-step-up.test.tsx b/apps/desktop/src/app/settings/billing/use-step-up.test.tsx new file mode 100644 index 000000000000..ee90129e5b81 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-step-up.test.tsx @@ -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 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) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts new file mode 100644 index 000000000000..7658dc917567 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -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(null) + const [phase, setPhase] = useState('idle') + const [verification, setVerification] = useState(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('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 granting billing management access.', + title: 'Verification was not approved' + }) + + return + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }), + queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] }) + ]) + setMessage({ + kind: 'success', + text: 'Billing management access was verified.', + title: 'Verification complete' + }) + }, [api, gateway, queryClient, unsubscribe]) + + return { dismiss, message, openVerification, phase, start, verification } +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index f50047b1b7e3..e81cce91e06f 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -8,6 +8,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Archive, + BarChart3, Bell, Download, Globe, @@ -31,6 +32,7 @@ import { SKILLS_ROUTE } from '../routes' import { AboutSettings } from './about-settings' import { AppearanceSettings } from './appearance-settings' +import { BillingSettings } from './billing' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' @@ -49,6 +51,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'keybinds', 'keys', 'notifications', + 'billing', 'plugins', 'sessions', 'about' @@ -150,6 +153,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.notifications, onSelect: () => setActiveView('notifications') }, + { + active: activeView === 'billing', + icon: BarChart3, + id: 'billing', + label: t.settings.nav.billing, + onSelect: () => setActiveView('billing') + }, { active: activeView === 'providers', children: [ @@ -293,6 +303,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( + ) : activeView === 'billing' ? ( + ) : activeView === 'plugins' ? ( ) : ( diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index f7fd585547b8..2828609ef63f 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -6,6 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes' export type SettingsView = | 'about' + | 'billing' | 'gateway' | 'keybinds' | 'keys' diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a5262b6ba65c..62bf96932251 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -322,6 +322,7 @@ export const en: Translations = { mcp: 'MCP', archivedChats: 'Archived Chats', about: 'About', + billing: 'Billing', notifications: 'Notifications', plugins: 'Plugins' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index bd7247976b37..31f6d486208a 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -223,6 +223,7 @@ export const ja = defineLocale({ mcp: 'MCP', archivedChats: 'アーカイブ済みチャット', about: '情報', + billing: '請求', notifications: '通知' }, notifications: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 1c38f6201353..851c794c2091 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -280,6 +280,7 @@ export interface Translations { mcp: string archivedChats: string about: string + billing: string notifications: string plugins: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9aa700ffab7d..c8e3f04a33cc 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -217,6 +217,7 @@ export const zhHant = defineLocale({ mcp: 'MCP', archivedChats: '已封存聊天', about: '關於', + billing: '帳單', notifications: '通知' }, notifications: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a68a1b250746..8928b646158f 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -313,6 +313,7 @@ export const zh: Translations = { mcp: 'MCP', archivedChats: '已归档对话', about: '关于', + billing: '账单', notifications: '通知', plugins: '插件' }, diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 43ed639c3c99..8af5d47ffb29 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -18,6 +18,7 @@ "paths": { "@/*": ["./src/*"], "@hermes/plugin-sdk": ["./src/sdk/index.ts"], + "@hermes/shared/billing": ["../shared/src/billing-types.ts"], "@hermes/shared": ["../shared/src/index.ts"] } }, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 4110d53aeebe..2b0685c9a0b0 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src'), '@hermes/plugin-sdk': path.resolve(__dirname, './src/sdk/index.ts'), + '@hermes/shared/billing': path.resolve(__dirname, '../shared/src/billing-types.ts'), '@hermes/shared': path.resolve(__dirname, '../shared/src'), react: path.resolve(__dirname, '../../node_modules/react'), 'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'),