From 9bbfe926bc611c012efc67901312018b5ef73536 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 13 Jun 2026 11:59:21 +0530 Subject: [PATCH] feat(billing): /billing Ink TUI screens + tests (phase 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui-tui/src/app/slash/commands/billing.ts: /billing TUI command covering all 5 screens — overview (text), buy → ConfirmReq → charge → non-blocking 2s/ 5-min poll loop → settled/failed/timeout branches, auto-reload → ConfirmReq → PATCH, limit (read-only). Reuses the existing ConfirmReq overlay (D-C) — no bespoke component. Typed-error envelope branching: insufficient_scope arms the lazy step-up confirm; no_payment_method/rate_limited/cap funnel to portal. Client-side amount validation mirrors the server (bounds + 2dp). - gatewayTypes.ts: Billing* response interfaces. - registry.ts: register billingCommands. - billingCommand.test.ts: 12 vitest cases (overview/gating/buy-confirm-poll- settled/no_payment_method/step-up/limit/auto-reload/validation). TUI build green; 12/12 vitest pass; slash tests pass once @hermes/ink is built. --- ui-tui/src/__tests__/billingCommand.test.ts | 210 +++++++++++ ui-tui/src/app/slash/commands/billing.ts | 367 ++++++++++++++++++++ ui-tui/src/app/slash/registry.ts | 2 + ui-tui/src/gatewayTypes.ts | 76 ++++ 4 files changed, 655 insertions(+) create mode 100644 ui-tui/src/__tests__/billingCommand.test.ts create mode 100644 ui-tui/src/app/slash/commands/billing.ts diff --git a/ui-tui/src/__tests__/billingCommand.test.ts b/ui-tui/src/__tests__/billingCommand.test.ts new file mode 100644 index 00000000000..11900931129 --- /dev/null +++ b/ui-tui/src/__tests__/billingCommand.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { billingCommands } from '../app/slash/commands/billing.js' +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import type { BillingStateResponse } from '../gatewayTypes.js' + +vi.mock('../lib/openExternalUrl.js', () => ({ + openExternalUrl: vi.fn(() => true) +})) + +const billingCommand = billingCommands.find(cmd => cmd.name === 'billing')! + +const ownerState = (overrides: Partial = {}): BillingStateResponse => ({ + auto_reload: { 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: ['100', '250', '500'], + charge_presets_display: ['$100', '$250', '$500'], + 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 = + (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) => { + 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) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + billingCommand.run(arg, ctx as any, 'billing') + await rpc.mock.results[0]?.value + await Promise.resolve() + await Promise.resolve() + } + return { calls, ctx, rpc, run, sys } +} + +const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') + +describe('/billing slash command', () => { + 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().confirm).toBeNull() + }) + + it('overview renders balance, cap, and actions for an admin', async () => { + const { run, sys, rpc } = buildCtx({ 'billing.state': ownerState() }) + await run('') + expect(rpc).toHaveBeenCalledWith('billing.state', {}) + const out = printed(sys) + expect(out).toContain('💳 Usage credits') + expect(out).toContain('Balance: $142.50') + expect(out).toContain('$180 of $1000 used (default ceiling)') + expect(out).toContain('/billing buy') + expect(out).toContain('Manage on portal:') + }) + + it('member sees gated message, no buy actions', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState({ is_admin: false, can_charge: false, role: 'MEMBER', card: null, monthly_cap: null, auto_reload: null }) + }) + await run('') + expect(printed(sys)).toContain('require an org admin/owner') + }) + + it('buy arms a confirm overlay with consent + total', async () => { + const { run, sys } = buildCtx({ 'billing.state': ownerState() }) + await run('buy 100') + const confirm = getOverlayState().confirm + expect(confirm).toBeTruthy() + expect(confirm?.title).toBe('Buy $100 in credits?') + expect(confirm?.confirmLabel).toBe('Pay $100') + expect(confirm?.detail).toContain('Total due: $100') + expect(confirm?.detail).toContain('Nous Research') + expect(confirm?.detail).toContain('visa ····4242') + // overview should NOT have printed an error + expect(printed(sys)).not.toContain('🔴') + }) + + it('buy rejects an out-of-bounds amount client-side (no overlay)', async () => { + const { run, sys } = buildCtx({ 'billing.state': ownerState() }) + await run('buy 5') + expect(printed(sys)).toContain('Minimum is $10') + expect(getOverlayState().confirm).toBeNull() + }) + + it('buy rejects sub-cent amount', async () => { + const { run, sys } = buildCtx({ 'billing.state': ownerState() }) + await run('buy 10.005') + expect(printed(sys)).toContain('2 decimal places') + expect(getOverlayState().confirm).toBeNull() + }) + + it('buy confirm → 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('buy 100') + const confirm = getOverlayState().confirm! + confirm.onConfirm() + // flush charge rpc + first poll + await vi.runAllTimersAsync() + const out = printed(sys) + expect(out).toContain('Charge submitted') + expect(out).toContain('✅ $100 added.') + } finally { + vi.useRealTimers() + } + }) + + it('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('buy 100') + getOverlayState().confirm!.onConfirm() + 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('charge insufficient_scope → arms step-up confirm', async () => { + const { run } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' } + }) + await run('buy 100') + getOverlayState().confirm!.onConfirm() // the buy-confirm + await Promise.resolve() + await Promise.resolve() + // The charge failed with insufficient_scope → a NEW confirm (step-up) is armed. + const stepUp = getOverlayState().confirm + expect(stepUp?.title).toBe('Grant terminal billing access?') + }) + + it('limit screen is read-only', async () => { + const { run, sys } = buildCtx({ 'billing.state': ownerState() }) + await run('limit') + const out = printed(sys) + expect(out).toContain('Monthly spend limit') + expect(out).toContain('$180 of $1000 used this month (default ceiling)') + expect(out).toContain('read-only') + expect(getOverlayState().confirm).toBeNull() + }) + + it('auto-reload arms a confirm overlay', async () => { + const { run } = buildCtx({ 'billing.state': ownerState() }) + await run('auto-reload 20 100') + const confirm = getOverlayState().confirm + expect(confirm?.title).toBe('Turn on auto-reload?') + expect(confirm?.detail).toContain('Below $20 → reload to $100') + expect(confirm?.confirmLabel).toBe('Agree and turn on') + }) + + it('auto-reload rejects reload-to <= threshold', async () => { + const { run, sys } = buildCtx({ 'billing.state': ownerState() }) + await run('auto-reload 100 50') + expect(printed(sys)).toContain('greater than the threshold') + expect(getOverlayState().confirm).toBeNull() + }) +}) diff --git a/ui-tui/src/app/slash/commands/billing.ts b/ui-tui/src/app/slash/commands/billing.ts new file mode 100644 index 00000000000..aa017d12517 --- /dev/null +++ b/ui-tui/src/app/slash/commands/billing.ts @@ -0,0 +1,367 @@ +import type { + BillingChargeResponse, + BillingChargeStatusResponse, + BillingMutationResponse, + BillingStateResponse +} from '../../../gatewayTypes.js' +import { openExternalUrl } from '../../../lib/openExternalUrl.js' +import { patchOverlayState } from '../../overlayStore.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +// Poll cadence (plan §5, frozen): 2s interval, 5-minute cap. +const POLL_INTERVAL_MS = 2000 +const POLL_CAP_MS = 5 * 60 * 1000 + +type Sys = (text: string) => void + +/** Render the role/kill-switch-gated overview (Screen 1) as transcript text. */ +const renderOverview = (sys: Sys, s: BillingStateResponse): void => { + const lines = ['💳 Usage credits', `Balance: ${s.balance_display}`] + if (s.monthly_cap && s.monthly_cap.limit_usd != null) { + const ceiling = s.monthly_cap.is_default_ceiling ? ' (default ceiling)' : '' + lines.push(`This month: ${s.monthly_cap.spent_display} of ${s.monthly_cap.limit_display} used${ceiling}`) + } + if (s.auto_reload) { + lines.push( + s.auto_reload.enabled + ? `Auto-reload: on — below ${s.auto_reload.threshold_display} → reload to ${s.auto_reload.reload_to_display}` + : 'Auto-reload: off' + ) + } + if (s.org_name) { + lines.push(`Org: ${s.org_name}${s.role ? ` · ${s.role}` : ''}`) + } + + if (!s.is_admin) { + lines.push('', 'Billing actions require an org admin/owner.') + } else if (!s.cli_billing_enabled) { + lines.push('', 'Terminal billing is turned off for this org — enable it on the portal.') + } else { + lines.push( + '', + 'Actions:', + ' /billing buy — buy credits', + ' /billing auto-reload — configure auto-reload', + ' /billing limit — view the monthly limit' + ) + if (s.charge_presets_display.length) { + lines.push(`Presets: ${s.charge_presets_display.join(', ')}`) + } + } + if (s.portal_url) { + lines.push('', `Manage on portal: ${s.portal_url}`) + } + sys(lines.join('\n')) +} + +/** Map a typed billing error envelope to user-facing copy + portal funnel. */ +const renderBillingError = ( + sys: Sys, + ctx: SlashRunCtx, + env: { error?: string; message?: string; portal_url?: string | null; retry_after?: number | null } +): void => { + const portal = env.portal_url + switch (env.error) { + case 'insufficient_scope': + armStepUp(sys, ctx) + return + case 'no_payment_method': + sys( + '💳 No saved card for terminal charges yet. Set one up on the portal ' + + "(one-time credit buys don't save a reusable card)." + ) + break + case 'cli_billing_disabled': + sys('🔴 Terminal billing is turned off for this org. Enable it on the portal.') + break + case 'monthly_cap_exceeded': + sys(`🔴 ${env.message || 'Monthly spend cap reached.'}`) + break + case 'rate_limited': { + const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : '' + sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`) + break + } + default: + sys(`🔴 ${env.message || env.error || 'Billing request failed.'}`) + } + if (portal) { + sys(`Portal: ${portal}`) + } +} + +/** 403 insufficient_scope → arm a ConfirmReq that runs the lazy step-up. */ +const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => { + sys('💳 Terminal billing needs an extra permission (billing:manage).') + patchOverlayState({ + confirm: { + cancelLabel: 'Not now', + confirmLabel: 'Re-authorize', + detail: 'An org admin/owner must tick "Allow terminal billing" in the portal.', + onConfirm: () => { + ctx.gateway + .rpc('billing.step_up', {}) + .then( + ctx.guarded(r => { + if (r.ok && r.granted) { + sys('✅ Terminal billing enabled. Run /billing buy again to continue.') + } else { + sys('🟡 Terminal billing was not granted (an admin must tick the box).') + } + }) + ) + .catch(ctx.guardedErr) + }, + title: 'Grant terminal billing access?' + } + }) +} + +/** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */ +const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string): void => { + const start = Date.now() + const tick = (): void => { + if (ctx.stale()) { + return + } + ctx.gateway + .rpc('billing.charge_status', { charge_id: chargeId }) + .then( + ctx.guarded(r => { + if (!r.ok) { + // 429/503 while polling = retry-after, NOT a failure. Back off + continue. + if (r.error === 'rate_limited') { + const wait = (r.retry_after ?? 5) * 1000 + setTimeout(tick, Math.min(wait, 30000)) + return + } + sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`) + return + } + if (r.status === 'settled') { + sys(`✅ ${r.amount_usd ? `$${r.amount_usd}` : 'Credits'} added.`) + return + } + if (r.status === 'failed') { + renderChargeFailed(sys, ctx, r.reason) + return + } + // pending → keep polling until the 5-min cap, then call it a timeout. + if (Date.now() - start >= POLL_CAP_MS) { + sys( + '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + + 'Check /billing or the portal shortly.' + ) + return + } + setTimeout(tick, POLL_INTERVAL_MS) + }) + ) + .catch(ctx.guardedErr) + } + tick() +} + +const renderChargeFailed = (sys: Sys, _ctx: SlashRunCtx, reason?: string | null): void => { + switch ((reason || '').trim()) { + case 'authentication_required': + sys('🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.') + break + case 'payment_method_expired': + sys('🔴 Your card has expired. Update it on the portal.') + break + case 'card_declined': + sys('🔴 Your card was declined. Try another card on the portal.') + break + default: + sys(`🔴 The charge didn't go through (${reason || 'processing_error'}).`) + } +} + +/** Validate a custom amount against state bounds + 2dp, mirroring the server. */ +const validateAmount = (raw: string, s: BillingStateResponse): { amount?: string; error?: string } => { + const cleaned = raw.trim().replace(/^\$/, '').trim() + if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) { + return { error: 'Enter a dollar amount, e.g. 100 (max 2 decimal places).' } + } + const value = Number(cleaned) + if (!(value > 0)) { + return { error: 'Amount must be greater than $0.' } + } + if (s.min_usd != null && value < Number(s.min_usd)) { + return { error: `Minimum is $${s.min_usd}.` } + } + if (s.max_usd != null && value > Number(s.max_usd)) { + return { error: `Maximum is $${s.max_usd}.` } + } + return { amount: cleaned } +} + +/** `/billing buy ` → confirm (Screen 4) → charge → poll. */ +const runBuy = (arg: string, ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): void => { + if (!s.is_admin || !s.cli_billing_enabled) { + sys('🔴 Buying credits requires an org admin/owner with terminal billing enabled.') + if (s.portal_url) { + sys(`Portal: ${s.portal_url}`) + } + return + } + const amountArg = arg.trim() + if (!amountArg) { + const presets = s.charge_presets_display.join(', ') || '(none)' + sys(`Usage: /billing buy \nPresets: ${presets}`) + return + } + const v = validateAmount(amountArg, s) + if (v.error || !v.amount) { + sys(`🔴 ${v.error}`) + return + } + const amount = v.amount + const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file' + patchOverlayState({ + confirm: { + cancelLabel: 'Cancel', + confirmLabel: `Pay $${amount}`, + detail: + `Total due: $${amount} (one-time credits — no tax)\n${payLine}\n\n` + + 'By confirming, you allow Nous Research to charge your card in the amount above.', + onConfirm: () => { + sys('💳 Charge submitted — confirming settlement…') + ctx.gateway + .rpc('billing.charge', { amount_usd: amount }) + .then( + ctx.guarded(r => { + if (r.ok && r.charge_id) { + pollCharge(sys, ctx, r.charge_id) + } else { + renderBillingError(sys, ctx, r) + } + }) + ) + .catch(ctx.guardedErr) + }, + title: `Buy $${amount} in credits?` + } + }) +} + +/** `/billing auto-reload ` → confirm (Screen 2) → PATCH. */ +const runAutoReload = (arg: string, ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): void => { + if (!s.is_admin || !s.cli_billing_enabled) { + sys('🔴 Auto-reload requires an org admin/owner with terminal billing enabled.') + return + } + if (!s.card) { + sys('🔴 No saved card — set one up on the portal first.') + if (s.portal_url) { + sys(`Portal: ${s.portal_url}`) + } + return + } + const parts = arg.trim().split(/\s+/).filter(Boolean) + if (parts.length !== 2) { + sys('Usage: /billing auto-reload ') + return + } + const tv = validateAmount(parts[0], s) + const rv = validateAmount(parts[1], s) + if (tv.error || !tv.amount) { + sys(`🔴 Threshold: ${tv.error}`) + return + } + if (rv.error || !rv.amount) { + sys(`🔴 Reload-to: ${rv.error}`) + return + } + if (Number(rv.amount) <= Number(tv.amount)) { + sys('🔴 Reload-to amount must be greater than the threshold.') + return + } + const threshold = tv.amount + const reloadTo = rv.amount + patchOverlayState({ + confirm: { + cancelLabel: 'Cancel', + confirmLabel: 'Agree and turn on', + detail: + `Below $${threshold} → reload to $${reloadTo}\nCard: ${s.card.masked}\n\n` + + 'By turning this on, you authorize Nous Research to automatically charge this card ' + + 'whenever your balance falls below the threshold. Turn off any time here or on the portal.', + onConfirm: () => { + ctx.gateway + .rpc('billing.auto_reload', { + enabled: true, + threshold: Number(threshold), + top_up_amount: Number(reloadTo) + }) + .then( + ctx.guarded(r => { + if (r.ok) { + sys(`✅ Auto-reload on: below $${threshold} → reload to $${reloadTo}.`) + } else { + renderBillingError(sys, ctx, r) + } + }) + ) + .catch(ctx.guardedErr) + }, + title: 'Turn on auto-reload?' + } + }) +} + +/** `/billing limit` → read-only monthly cap (Screen 5). */ +const runLimit = (sys: Sys, s: BillingStateResponse): void => { + const lines = ['💳 Monthly spend limit'] + if (s.monthly_cap && s.monthly_cap.limit_usd != null) { + const ceiling = s.monthly_cap.is_default_ceiling ? ' (default ceiling)' : '' + lines.push(`${s.monthly_cap.spent_display} of ${s.monthly_cap.limit_display} used this month${ceiling}`) + } else { + lines.push('No monthly cap visible (managed on the portal).') + } + lines.push('The monthly limit is set on the portal — the terminal shows it read-only.') + if (s.portal_url) { + lines.push(`Manage on portal: ${s.portal_url}`) + } + sys(lines.join('\n')) +} + +export const billingCommands: SlashCommand[] = [ + { + help: 'Manage Nous terminal billing — buy credits, auto-reload, limits', + name: 'billing', + run: (arg, ctx) => { + const sys: Sys = ctx.transcript.sys + const raw = (arg || '').trim() + const [subRaw, ...rest] = raw.split(/\s+/) + const sub = (subRaw || '').toLowerCase() + const subArg = rest.join(' ') + + ctx.gateway + .rpc('billing.state', {}) + .then( + ctx.guarded(s => { + if (!s.logged_in) { + sys('💳 Not logged into Nous Portal — run /portal to log in, then /billing.') + return + } + if (sub === 'buy' || sub === 'credits' || sub === 'topup' || sub === 'top-up') { + runBuy(subArg, ctx, sys, s) + } else if (sub === 'auto-reload' || sub === 'autoreload' || sub === 'auto' || sub === 'reload') { + runAutoReload(subArg, ctx, sys, s) + } else if (sub === 'limit' || sub === 'cap' || sub === 'monthly') { + runLimit(sys, s) + } else if (sub === 'portal' && s.portal_url) { + const url = s.portal_url + openExternalUrl(url) + sys(`Opening portal: ${url}`) + } else { + renderOverview(sys, s) + } + }) + ) + .catch(ctx.guardedErr) + } + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 7f2d95195f4..c9192f5d56d 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,4 +1,5 @@ import { coreCommands } from './commands/core.js' +import { billingCommands } from './commands/billing.js' import { creditsCommands } from './commands/credits.js' import { debugCommands } from './commands/debug.js' import { opsCommands } from './commands/ops.js' @@ -8,6 +9,7 @@ import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ ...coreCommands, + ...billingCommands, ...creditsCommands, ...sessionCommands, ...opsCommands, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 00a3b458911..307d293921a 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -53,6 +53,82 @@ export interface CreditsViewResponse { topup_url: string | null } +// ── Terminal billing (Phase 2b) ────────────────────────────────────── + +export interface BillingCardInfo { + brand: string + last4: string + masked: string +} + +export interface BillingMonthlyCap { + is_default_ceiling: boolean + limit_display: string + limit_usd: string | null + spent_display: string + spent_this_month_usd: string | null +} + +export interface BillingAutoReload { + enabled: boolean + reload_to_display: string + reload_to_usd: string | null + threshold_display: string + threshold_usd: string | null +} + +export interface BillingStateResponse { + auto_reload: BillingAutoReload | null + balance_display: string + balance_usd: string | null + can_charge: boolean + card: BillingCardInfo | null + charge_presets: string[] + charge_presets_display: string[] + cli_billing_enabled: boolean + error?: string | null + is_admin: boolean + logged_in: boolean + max_usd: string | null + min_usd: string | null + monthly_cap: BillingMonthlyCap | null + ok: boolean + org_name: string | null + portal_url: string | null + role: string | null +} + +export interface BillingChargeResponse { + charge_id?: string + error?: string + idempotency_key?: string + message?: string + ok: boolean + portal_url?: string | null + retry_after?: number | null +} + +export interface BillingChargeStatusResponse { + amount_usd?: string | null + error?: string + message?: string + ok: boolean + portal_url?: string | null + reason?: string | null + retry_after?: number | null + settled_at?: string | null + status?: string +} + +export interface BillingMutationResponse { + error?: string + granted?: boolean + message?: string + ok: boolean + portal_url?: string | null + retry_after?: number | null +} + export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' }