hermes-agent/ui-tui/scripts/billing-fixtures.tsx
Siddharth Balyan 94c944363c
feat(tui): show the plan catalog in /subscription on Free (#68357)
* feat(tui): show the plan catalog in /subscription on Free

The server returns the tier list even with no subscription, but the
overlay hid the picker behind can_change_plan && !isFree, so a Free
account got only "Start a subscription" with no idea what the plans
cost. Now:

- Overview on Free offers "Choose a plan" whenever the catalog has
  enabled paid tiers.
- The picker on Free lists each plan as name · price · monthly credits
  (no upgrade/downgrade hints — there is nothing to move from), and
  picking one opens the portal, where starting a subscription actually
  happens (card capture + checkout live there; the upgrade RPC requires
  an existing subscription).
- Paid-plan behavior (preview → confirm → apply) is unchanged.

* refactor(tui): compute the picker row suffix once

Review feedback: the isFree fork duplicated the label template and run
handler; only the suffix differs.

* fix(tui): arm the busy guard before the Free portal handoff

Adversarial review: the Free branch returned before setting busyRef, so
a double-Enter could open the portal twice; and the picker narrated a
handoff that openManageLink already narrates (duplicate on success,
contradictory on failure). Guard first, let the helper do the talking.

* fix(tui): monthly credits are dollars — label them as such

The Free picker showed "1000 credits/mo" for what is $1,000 of monthly
credit — render "$1,000 credits/mo" (grouped, dollar-signed).

* feat(tui): render the Free-plan catalog inline in the /subscription overview

Sid ruling: the upsell belongs where the user already is — no
intermediate "Choose a plan" hop. On Free the overview lists each paid
plan (name · $/mo · $credits/mo) as a pickable row; picking opens the
portal (openManageLink narrates). The generic "Start a subscription"
row survives only when the catalog is empty. The picker reverts to its
original change-only form (Free never reaches it).

* feat(desktop): tier catalog chips on the Subscription row

Desktop parity with the TUI inline catalog (Sid ruling): accounts that
can act see the plans where they already are — Free gets the upsell
list (every chip opens the portal), a subscriber sees all tiers with
the current one marked inert. Members and team contexts see no chips.
Chips learn an optional url (portal handoff) in the shared row model.

* chore(tui): fixture harness mirrors the live tier catalog

The dev screenshot fixtures showed invented plans ($50 Super / $99
Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200
with $22/$110/$220 monthly credits) so fixture renders cannot be
mistaken for product truth. The overlay itself always reads tiers from
the subscription API.

* chore: trim narration comments
2026-07-21 12:20:59 +05:30

244 lines
9.4 KiB
TypeScript

/**
* Billing/Subscription TUI fixture harness — renders any single overlay STATE
* live in the terminal so it can be screenshotted (tmux) and UX-reviewed.
*
* This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and
* mounts the real Ink overlay components directly with a hand-built state object,
* exactly the way the vitest render tests do — so what you see is pixel-identical
* to what `/subscription` and `/topup` draw at runtime.
*
* Usage:
* npx tsx scripts/billing-fixtures.tsx <fixture-name>
* npx tsx scripts/billing-fixtures.tsx --list
*
* Drive a specific screen of a fixture with SCREEN=<screen>, e.g.:
* SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free
* SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid
*
* The selection cursor can be moved with ↑/↓ once it's live (the components own
* their own useInput); Esc/Enter behave as in production. Ctrl-C to exit.
*/
import { render } from '@hermes/ink'
import React from 'react'
import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js'
import { BillingOverlay } from '../src/components/billingOverlay.js'
import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js'
import { DEFAULT_THEME } from '../src/theme.js'
const t = DEFAULT_THEME
// ── helpers ──────────────────────────────────────────────────────────
const tier = (o: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true,
...o
})
// Mirrors the live portal catalog so fixtures don't drift; the real overlay
// reads tiers from GET /api/billing/subscription, never from here.
const TIERS = {
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '22' }),
super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$100', monthly_credits: '110' }),
ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$200', monthly_credits: '220' })
}
const tierList = (currentId?: string): SubscriptionTierOption[] =>
Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId }))
const subState = (o: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme Inc',
org_id: 'org_acme',
role: 'OWNER',
context: 'personal',
current: null,
tiers: tierList(),
portal_url: 'https://portal.nousresearch.com/billing',
...o
})
const cur = (o: Record<string, unknown> = {}) => ({
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '420',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null,
cancel_at_period_end: false,
cancellation_effective_at: null,
...o
})
const subCtx: SubscriptionOverlayState['ctx'] = {
openManageLink: () => Promise.resolve(true),
refreshState: () => Promise.resolve(null),
sys: () => {}
}
const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({
ctx: subCtx,
screen,
state: s,
pendingTargetTierId
})
// ── billing/topup fixtures ───────────────────────────────────────────
const billState = (o: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
cli_billing_enabled: true,
can_charge: true,
card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' },
balance_display: '$12.00',
balance_usd: '12.00',
min_usd: '5',
max_usd: '500',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$20',
limit_usd: '20',
spent_display: '$8.00',
spent_this_month_usd: '8'
},
auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' },
org_name: 'Acme Inc',
role: 'OWNER',
portal_url: 'https://portal.nousresearch.com/billing',
charge_presets: ['10', '25', '50', '100'],
charge_presets_display: ['$10', '$25', '$50', '$100'],
...o
})
const billCtx = {
applyAutoReload: () => Promise.resolve(true),
charge: () => Promise.resolve('submitted' as const),
openPortal: () => {},
requestRemoteSpending: () => Promise.resolve(true),
sys: () => {},
validate: (raw: string) => ({ amount: raw })
}
const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({
ctx: billCtx,
pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null,
screen,
state: s
})
// ── fixture registry ─────────────────────────────────────────────────
type Fixture = { desc: string; node: React.ReactElement }
const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) =>
React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t })
const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') =>
React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t })
const FIXTURES: Record<string, Fixture> = {
// /subscription — overview states
'sub-free': {
desc: 'Free / no sub — upgradeable (primary conversion state)',
node: subEl(subState({ current: null }))
},
'sub-mid': {
desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }))
},
'sub-top': {
desc: 'Subscriber top-tier (Ultra) — "on the top plan"',
node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') }))
},
'sub-not-admin': {
desc: 'Member (not admin/owner) — read-only, no tier picker',
node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') }))
},
'sub-downgrade': {
desc: 'Downgrade scheduled — pending-switch banner',
node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') }))
},
'sub-cancel': {
desc: 'Cancellation scheduled — stays active until effective date',
node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') }))
},
'sub-team': {
desc: 'Team org context — shared credits, redirect to /topup',
node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' }))
},
// /subscription — non-overview screens
'sub-confirm': {
desc: 'Confirm plan change (deep-link, no in-terminal charge)',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super')
},
'sub-confirm-new': {
desc: 'Confirm first subscription (free → paid)',
node: subEl(subState({ current: null }), 'confirm', 'plus')
},
'sub-handoff': {
desc: 'Handoff transient — opening subscription page in browser',
node: subEl(subState({ current: cur() }), 'handoff')
},
// /topup (renamed /billing)
'topup-overview': {
desc: '/topup overview — admin, card on file, full menu',
node: billEl(billState())
},
'topup-no-card': {
desc: '/topup overview — admin, NO saved card (card hint)',
node: billEl(billState({ card: null }))
},
'topup-not-admin': {
desc: '/topup overview — member, read-only',
node: billEl(billState({ is_admin: false }))
},
'topup-disabled': {
desc: '/topup overview — remote spending OFF for org',
node: billEl(billState({ cli_billing_enabled: false }))
},
'topup-buy': {
desc: '/topup buy screen — presets',
node: billEl(billState(), 'buy')
},
'topup-stepup': {
desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)',
node: billEl(billState(), 'stepup')
}
}
// ── driver ───────────────────────────────────────────────────────────
const arg = process.argv[2]
if (!arg || arg === '--list' || arg === '-l') {
const names = Object.keys(FIXTURES)
process.stdout.write('Billing/Subscription TUI fixtures:\n\n')
for (const name of names) {
process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`)
}
process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx <name>\n`)
process.exit(0)
}
const fixture = FIXTURES[arg]
if (!fixture) {
process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`)
process.exit(1)
}
render(fixture.node)