hermes-agent/ui-tui/src/__tests__/topupCommand.test.ts
Siddharth Balyan b0da653ac8
fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending

The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.

- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
  admin can turn it on from the portal's Hermes Agent page") instead of
  the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
  unchanged; copy, comments, docs, and test expectations only.

* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename

Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
2026-07-21 12:20:25 +05:30

467 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { topupCommands } from '../app/slash/commands/topup.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!
const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '—',
reload_to_usd: null,
threshold_display: '—',
threshold_usd: null
},
balance_display: '$142.50',
balance_usd: '142.5',
can_charge: true,
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
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: true,
limit_display: '$1000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
ok: true,
org_name: 'Acme',
portal_url: 'https://portal/billing?topup=open',
role: 'OWNER',
...overrides
})
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const calls: Array<{ method: string; params: unknown }> = []
const rpc = vi.fn((method: string, params: unknown) => {
calls.push({ method, params })
return Promise.resolve(results[method])
})
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
const run = async (arg: string) => {
topupCommand.run(arg, ctx as any, 'topup')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { calls, ctx, rpc, run, sys }
}
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/billing slash command (overlay-driven)', () => {
beforeEach(() => {
resetOverlayState()
})
it('not logged in → prompts to log in, no overlay', async () => {
const { run, sys } = buildCtx({ 'billing.state': { ...ownerState(), logged_in: false, ok: true } })
await run('')
expect(printed(sys)).toContain('Not logged into Nous Portal')
expect(getOverlayState().billing).toBeNull()
})
it('bare /billing opens the overlay on the overview screen with state', async () => {
const { run, rpc } = buildCtx({ 'billing.state': ownerState() })
await run('')
expect(rpc).toHaveBeenCalledWith('billing.state', {})
const billing = getOverlayState().billing
expect(billing).toBeTruthy()
expect(billing?.screen).toBe('overview')
expect(billing?.state.balance_display).toBe('$142.50')
expect(billing?.state.charge_presets_display).toEqual(['$25', '$50', '$100'])
})
it('any sub-command arg is ignored — still opens the overview overlay', async () => {
const { run } = buildCtx({ 'billing.state': ownerState() })
await run('buy 100')
const billing = getOverlayState().billing
expect(billing?.screen).toBe('overview')
// No confirm overlay armed directly by the command anymore.
expect(getOverlayState().confirm).toBeNull()
})
it('member overview carries the non-admin state for component-side gating', async () => {
const { run } = buildCtx({
'billing.state': ownerState({
is_admin: false,
can_charge: false,
role: 'MEMBER',
card: null,
monthly_cap: null,
auto_reload: null
})
})
await run('')
const billing = getOverlayState().billing
expect(billing?.state.is_admin).toBe(false)
expect(billing?.screen).toBe('overview')
})
// ── Overlay ctx behaviors (RPC + error mapping live in billing.ts) ──
it('ctx.validate rejects out-of-bounds and sub-cent amounts, accepts valid', async () => {
const { run } = buildCtx({ 'billing.state': ownerState() })
await run('')
const ctx = getOverlayState().billing!.ctx
expect(ctx.validate('5').error).toContain('Minimum is $10')
expect(ctx.validate('10.005').error).toContain('2 decimal places')
expect(ctx.validate('100').amount).toBe('100')
expect(ctx.validate('$50').amount).toBe('50')
})
it('ctx.charge → poll → settled', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' }
})
await run('')
const ctx = getOverlayState().billing!.ctx
ctx.charge('100')
await vi.runAllTimersAsync()
const out = printed(sys)
expect(out).toContain('Charge submitted')
expect(out).toContain('✅ $100 added.')
} finally {
vi.useRealTimers()
}
})
it('ctx.charge → poll → failed adds the portal funnel line', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'failed', reason: 'card_declined' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await vi.runAllTimersAsync()
const out = printed(sys)
expect(out).toContain('Your card was declined')
// Parity with the CLI: a failed poll funnels to the portal (from state.portal_url).
expect(out).toContain('Portal: https://portal/billing?topup=open')
} finally {
vi.useRealTimers()
}
})
it('ctx.charge monthly_cap_exceeded surfaces remaining headroom', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '42.50' },
portal_url: '/billing?topup=open',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Monthly spend cap reached — $42.50 headroom left.')
expect(out).toContain('Portal: /billing?topup=open')
})
it('ctx.charge no_payment_method → portal funnel copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'no_payment_method',
portal_url: '/billing?topup=open',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('No saved card for terminal charges')
expect(out).toContain('Portal: /billing?topup=open')
})
it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'consent_required',
portal_url: '/billing/consent',
idempotency_key: 'k'
}
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
const out = printed(sys)
expect(out).toContain('one-time card confirmation')
expect(out).toContain('Portal: /billing/consent')
})
it.each([
['org_access_denied', "This token isn't bound to an org you can manage"],
['upgrade_cap_exceeded', 'Daily plan-change limit reached'],
['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures']
])('ctx.charge %s → typed recovery copy', async (error, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error, idempotency_key: 'k' }
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
expect(printed(sys)).toContain(copy)
})
it.each([
[undefined, 'Stripe is having trouble right now — try again shortly.'],
[120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).']
])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'stripe_unavailable',
idempotency_key: 'k',
...(retryAfter == null ? {} : { retry_after: retryAfter })
}
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
const out = printed(sys)
expect(out).toContain(copy)
expect(out).not.toContain('Too many charges')
})
it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
const { run } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
})
await run('')
const outcome = await getOverlayState().billing!.ctx.charge('100')
// No separate confirm overlay is armed anymore — the overlay's stepup
// screen owns the UX; the ctx just reports the outcome.
expect(outcome).toBe('needs_remote_spending')
expect(getOverlayState().confirm).toBeNull()
})
it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => {
const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } })
await run('')
expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted)
expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy()
})
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
it.each([
['admin', 'An admin stopped remote spending for this terminal'],
['self', 'You stopped remote spending for this terminal']
])(
'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy',
async (actor, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'remote_spending_revoked',
actor,
recovery: 'reconnect',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain(copy)
expect(getOverlayState().billing).toBeNull()
}
)
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain('Your session was logged out')
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => {
const { ctx, rpc, run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }
})
await run('')
rpc.mockImplementation((method: string) => {
if (method === 'billing.charge_status') {
return Promise.reject(new Error('socket closed'))
}
return Promise.resolve(method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null)
})
await getOverlayState().billing!.ctx.charge('100')
await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed'))
expect(ctx.guardedErr).toHaveBeenCalled()
})
it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'cli_billing_disabled',
code: 'remote_spending_disabled',
recovery: 'enable_account_toggle',
portal_url: '/billing',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Remote spending is off for this account')
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
expect(getOverlayState().billing).toBeTruthy()
})
it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {
const { run, calls } = buildCtx({
'billing.state': ownerState(),
'billing.auto_reload': { ok: true }
})
await run('')
const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
expect(ok).toBe(true)
const ar = calls.find(c => c.method === 'billing.auto_reload')
expect(ar?.params).toEqual({ enabled: true, threshold: 20, top_up_amount: 100 })
})
it('ctx.applyAutoReload(false) → disables (enabled:false, no amounts)', async () => {
const { run, calls } = buildCtx({
'billing.state': ownerState({
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$20',
threshold_usd: '20'
}
}),
'billing.auto_reload': { ok: true }
})
await run('')
const ok = await getOverlayState().billing!.ctx.applyAutoReload(false)
expect(ok).toBe(true)
const ar = calls.find(c => c.method === 'billing.auto_reload')
expect(ar?.params).toEqual({ enabled: false })
})
it('ctx.applyAutoReload error → resolves false + maps the error', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.auto_reload': { ok: false, error: 'monthly_cap_exceeded', message: 'Monthly spend cap reached.' }
})
await run('')
const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
expect(ok).toBe(false)
expect(printed(sys)).toContain('Monthly spend cap reached.')
})
it('ctx.charge → poll → processing_error has intentional failure copy', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await vi.runAllTimersAsync()
expect(printed(sys)).toContain("The charge didn't go through (processing_error).")
} finally {
vi.useRealTimers()
}
})
it('ctx.openPortal opens the URL + echoes a transcript line', async () => {
const { run, sys } = buildCtx({ 'billing.state': ownerState() })
await run('')
getOverlayState().billing!.ctx.openPortal('https://portal/x')
expect(printed(sys)).toContain('Opening portal: https://portal/x')
})
})