feat(desktop): Billing page revamp — current-plan card, in-app plans view, tier art (#68722)

* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art

Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.

- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
  and at most one button — "View plans" (free/no-sub + can_change_plan),
  "Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
  Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
  navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
  default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
  (is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
  Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
  (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
  caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
  this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
  plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
  NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
  with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
  tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
  unknown name → text-only card. Imported via vite static imports for packaged
  file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
  top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
  copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
  $ fields with a pre-allocated error line) and the action column (Manage → Save/
  Cancel) in place, with the row height reserved for the tallest state so the Usage
  section never shifts. Fixes the spurious on-open validation error (errors now show
  only after an edit or a save attempt). Save/disable API calls + confirm-disable
  flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
  deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
  4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.

Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.

* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription

Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".

Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.

CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).

Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.

* chore(desktop): shrink bundled tier art to 128px thumbnails

The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.

* fix(desktop): address 6 adversarial-review findings on the Billing revamp

1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
   is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
   undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
   now resolves current identity/ordering against the UNFILTERED tiers and keeps the
   grandfathered current tier in the grid as the inert "Current plan" card; downgrades
   classify against its tier_order. (Non-current disabled tiers are still dropped.)

2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
   can_change_plan, but the grid could be empty / current-only and showPlans refused,
   so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
   actionable (non-current) tier; otherwise it falls back to the portal link.

3. Deep-link bypass. showPlans now gates on the same capability that renders the button
   (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
   back to overview instead of a grid of live Choose buttons.

4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
   refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
   link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
   no longer promises a portal the UI didn't render.

5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
   as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
   hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
   instead of a bare return, so a null portal_url never strips the routing params.

6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
   inputs stack below @2xl) with exact reservation: the edit form is always rendered and
   both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
   when not editing — the row equals the tallest state at every width, no breakpoint math.
   The refusal stays inside the reserved layer.

Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): reuse the shared openExternalLink helper in the plans view

* fix(desktop): honor the auto_reload wire contract — null card + disable amounts

A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:

A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
   for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
   but the shared BillingAutoReload.card union had no null arm and use-billing-state
   dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
   to the shared union (contract honesty) and guard the read (`card?.kind`); null now
   falls through to the default enabled path, same as a canonical card.

B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
   threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
   invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
   disable() now sends the current threshold_usd/reload_to_usd from the autoReload
   prop alongside enabled: false, matching the TUI.

Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.

* fix(tui): guard the nullable auto_reload card in the auto-reload screen

The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.

* fix(desktop): align billing inputs to the sm control height

The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.

* fix(desktop): plan-card actionability + billing view-model hardening

Code-quality review of the Billing revamp (PR #68722).

BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.

Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
  derived once in deriveBillingView and threaded to BOTH derivePlanCard and
  derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
  lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
  disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
  action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
  defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
  its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
  `accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
  AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
  from RowValue (via a destructured const) and the plan-card link handler.

Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.

* refactor(desktop): adopt inline-review nits on the billing plan card

Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
  (union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
  `tier.action && …`) rather than relying on outer narrowing.

Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.
This commit is contained in:
Siddharth Balyan 2026-07-22 08:07:52 +05:30 committed by GitHub
parent 5c4c419307
commit 7c68b4eae1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1368 additions and 374 deletions

View file

@ -1,5 +1,5 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from './types'
const current = (
overrides: Partial<NonNullable<SubscriptionStateResponse['current']>> = {}
@ -207,6 +207,110 @@ export const loggedOutSubscriptionState = {
usage: undefined
} satisfies SubscriptionStateResponse
// Full four-tier personal catalog. tier_ids are cuid-like (Prisma) on purpose:
// tier art keys off the lowercase NAME, never the id (ids differ per env). Dollar
// credits are 0.1 / 22 / 110 / 220 to exercise the "$X credits/mo" formatting.
const personalTierCatalog: SubscriptionTierOption[] = [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier000free0000personal',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'cltier111plus1111personal',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 'cltier222super222personal',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: false,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'cltier333ultra333personal',
tier_order: 3
}
]
const catalogWithCurrent = (currentTierId: null | string): SubscriptionTierOption[] =>
personalTierCatalog.map(tier => ({ ...tier, is_current: tier.tier_id === currentTierId }))
// Logged-in personal org, no subscription: exercises the "View plans" plan card
// and the full plans grid where every tier is an upgrade.
export const freePersonalBillingState = {
...postTrainBillingState,
balance_display: '$12.00',
balance_usd: '12.00',
org_name: 'Personal',
usage: {
available: true,
has_topup: true,
plan_name: 'Free',
renews_at: null,
renews_display: null,
status: 'active',
subscription_remaining_display: '$0',
topup_remaining_display: '$12.00',
total_spendable_display: '$12.00'
}
} satisfies BillingStateResponse
export const freePersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
org_id: 'org_personal_free',
org_name: 'Personal',
tiers: catalogWithCurrent(null),
usage: freePersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus: exercises the "Change plan" plan card, the current
// marker, upgrades (Super/Ultra), and the disabled downgrade (Free).
export const subscriberPersonalBillingState = {
...postTrainBillingState,
org_name: 'Personal',
usage: {
...postTrainBillingState.usage,
plan_name: 'Plus'
}
} satisfies BillingStateResponse
export const subscriberPersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
}),
org_id: 'org_personal_plus',
org_name: 'Personal',
tiers: catalogWithCurrent('cltier111plus1111personal'),
usage: subscriberPersonalBillingState.usage
} satisfies SubscriptionStateResponse
const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
@ -270,6 +374,14 @@ function withUsage(
export const billingDevFixtures = {
healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }),
'free-personal': {
billing: okBilling(freePersonalBillingState),
subscription: okSubscription(freePersonalSubscriptionState)
},
'subscriber-personal': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(subscriberPersonalSubscriptionState)
},
'auto-refill-divergent': withUsage('Auto Refill Divergent', {
autoReload: {
...postTrainBillingState.auto_reload,

View file

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@ -38,13 +39,15 @@ vi.mock('./api', () => ({
})
}))
function renderBilling() {
function renderBilling(initialEntries: string[] = ['/settings?tab=billing']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
)
return client
@ -79,7 +82,7 @@ describe('BillingSettings', () => {
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy()
expect(screen.getByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByText('$120 of $220 left')).toBeTruthy()
expect(screen.getByText('$876.47')).toBeTruthy()
expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true)
@ -163,6 +166,17 @@ describe('BillingSettings', () => {
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('renders the enabled auto-refill row without crashing when the card is null', async () => {
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } })
)
renderBilling()
expect(await screen.findByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
@ -176,7 +190,144 @@ describe('BillingSettings', () => {
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false }))
// The gateway requires threshold + top_up_amount even to disable, so the current
// amounts ride along (todayBillingState: threshold $5, reload-to $10).
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: false,
reload_to_usd: '10',
threshold_usd: '5'
})
)
})
it('opens auto-refill edit without a validation error even when the saved config is below the minimum', async () => {
// todayBillingState: threshold $5 with min_usd $10 — invalid, but opening
// Manage must stay silent until the user edits or attempts to save (spec §9).
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
expect(screen.queryByText('Threshold: minimum is $10.')).toBeNull()
// Save is disabled because the prefilled config is invalid — but no error yet.
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
})
it('navigates to the in-app plans grid from the plan card and back', async () => {
const fixture = billingDevFixtures['free-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'View plans' }))
expect(await screen.findByText('Plans')).toBeTruthy()
// No subscription → the free tier is the inert current plan, the three paid
// tiers are "Choose ↗" upgrades (no "subscribe to Free").
expect(screen.getByText('Current plan')).toBeTruthy()
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(3)
expect(screen.queryByRole('button', { name: 'Downgrade' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Back to billing' }))
expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy()
})
it('renders the current marker and disabled downgrade when deep-linked to the plans grid', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Current plan')).toBeTruthy()
// Free sits below Plus → disabled downgrade with the ticket-11 caption.
expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByText('Downgrades are moving in-app — coming soon.')).toBeTruthy()
// Super + Ultra are upgrades.
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(2)
})
it('falls back to overview (no live Choose grid) when a team deep-links bview=plans', async () => {
// Default beforeEach uses todaySubscriptionState (context: 'team') — no in-app
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a non-changer personal account deep-links bview=plans', async () => {
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a top-tier subscriber deep-links bview=plans', async () => {
// Capable, but on the highest tier → no upgrade → no in-app button → the deep
// link must not open a grid whose only actions are inert downgrades.
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'top',
tier_order: 1
}
]
})
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
// The plan card's portal link is present instead of an in-app button.
expect(screen.getByRole('button', { name: /Adjust plan/ })).toBeTruthy()
})
it('keeps the auto-refill edit form mounted so the row height is reserved before editing', async () => {
renderBilling()
await screen.findByRole('button', { name: 'Manage' })
// Not editing: the inputs are already in the DOM (height reserved) but aria-hidden,
// so the accessible query finds nothing while the hidden-inclusive query does.
expect(screen.queryByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold', hidden: true })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Manage' }))
// Editing reveals the same reserved input.
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {

View file

@ -5,19 +5,23 @@ 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 { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
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 { BillingPlansView } from './plans-view'
import { TierArt } from './tier-art'
import type { BillingAutoReload, BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingPlanCardView,
type BillingUsageRowView,
deriveBillingView,
EMPTY_BILLING_VALUE,
@ -25,6 +29,11 @@ import {
useBillingState,
useSubscriptionState
} from './use-billing-state'
// `bview` mirrors the settings pview/kview sub-view pattern (deep-linkable, replace
// navigation). `overview` is the default landing; `plans` is the in-app catalog.
const BILLING_VIEWS = ['overview', 'plans'] as const
type BillingSubView = (typeof BILLING_VIEWS)[number]
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
@ -84,6 +93,9 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
}
function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
// Destructure to a const so narrowing survives into the onClick closure below.
const { action } = row
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
@ -105,16 +117,16 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun
{chip.label}
</Button>
))}
{row.action && (
{action && (
<Button
disabled={row.action.disabled}
onClick={row.action.disabled ? undefined : onAction ? onAction : () => openExternal(row.action?.url)}
disabled={action.disabled}
onClick={action.disabled ? undefined : onAction ? onAction : () => openExternal(action.url)}
size="sm"
type="button"
variant="outline"
>
{row.action.label}
{!row.action.disabled && row.action.url && <ExternalLink className="size-3.5" />}
{action.label}
{!action.disabled && action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
@ -147,6 +159,46 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil
)
}
function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-3">
<TierArt name={plan.tierName} />
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{plan.tierName}
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.caption}
</div>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{plan.action && (
<Button onClick={onViewPlans} size="sm" type="button" variant="outline">
{plan.action.label}
</Button>
)}
{plan.link && (
<Button onClick={() => plan.link && openExternal(plan.link.url)} size="sm" type="button" variant="outline">
{plan.link.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
)
}
function AutoReloadRow({
autoReload,
bounds,
@ -160,6 +212,10 @@ function AutoReloadRow({
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
// Validation errors are silent until the user edits a field or attempts a
// save — opening Manage on a prefilled (possibly below-min) config must not
// flash an error (spec §9).
const [showErrors, setShowErrors] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
@ -178,12 +234,28 @@ function AutoReloadRow({
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
// Only the canonical-card enabled state edits in place (flagged in the view model).
// Off / divergent-card rows have no Manage affordance (or a portal link) and render
// read-only.
const editable = row.manageInApp === true
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const openEdit = () => {
resetFeedback()
setShowErrors(false)
setEditing(true)
}
const cancelEdit = () => {
resetFeedback()
setEditing(false)
}
const save = async () => {
if (!validation.values || busy) {
return
@ -219,7 +291,14 @@ function AutoReloadRow({
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({ enabled: false })
// The gateway's billing.auto_reload handler unconditionally requires threshold
// + top_up_amount (invalid_request otherwise), so a disable must still carry the
// current amounts — mirroring the TUI, which always sends both.
const result = await api.updateAutoReload({
enabled: false,
reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
})
setSaving(false)
@ -234,115 +313,149 @@ function AutoReloadRow({
setEditing(false)
}
const below = editing ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 h-8"
disabled={busy}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={event => {
resetFeedback()
setThreshold(event.target.value)
}}
step="0.01"
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 h-8"
disabled={busy}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={event => {
resetFeedback()
setReloadTo(event.target.value)
}}
step="0.01"
type="number"
value={reloadTo}
/>
</label>
</div>
{validation.error && (
<div className="text-[length:var(--conversation-caption-font-size)] text-destructive">{validation.error}</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(true)} size="sm" type="button" variant="outline">
Disable
</Button>
<Button
disabled={busy}
onClick={() => {
resetFeedback()
setEditing(false)
}}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
</div>
{confirmDisable && (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(false)} size="sm" type="button" variant="ghost">
Cancel
</Button>
</div>
)}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</div>
) : (
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
)
// Read-only states (off / divergent card) keep the original ListRow shape.
if (!editable) {
return (
<ListRow
action={<RowValue row={row} />}
below={
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
resetFeedback()
setShowErrors(true)
setter(event.target.value)
}
// Zero-shift by exact reservation, not a magic min-height: the edit form is
// ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
// so the row's height always equals the tallest state at EVERY container width —
// no breakpoint math that under-reserves when the two inputs stack on narrow
// panes. The form is `invisible` + `aria-hidden` when not editing.
return (
<ListRow
action={
<RowValue
onAction={
row.action?.url
? undefined
: () => {
resetFeedback()
setEditing(true)
}
}
row={row}
/>
}
below={below}
description={row.description}
key={row.id}
title={row.title}
/>
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-start">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{row.description}
</div>
<div className="mt-3 grid [grid-template-areas:'stack']">
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
<div
aria-hidden={!editing}
className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}
>
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setThreshold)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setReloadTo)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={reloadTo}
/>
</label>
</div>
{/* Pre-allocated error line — occupies height whether or not shown. */}
<div className="min-h-4 text-[length:var(--conversation-caption-font-size)] text-destructive">
{showErrors && validation.error ? validation.error : ''}
</div>
{confirmDisable ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(false)} size="sm" type="button" variant="ghost">
Cancel
</Button>
</div>
) : (
<Button
disabled={busy}
onClick={() => setConfirmDisable(true)}
size="sm"
tabIndex={editing ? undefined : -1}
type="button"
variant="outline"
>
Disable
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
<BillingRefusalInline refusal={refusal} />
</div>
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */}
{!editing && message && (
<div className="[grid-area:stack]">
<InlineMessage kind={message.kind}>{message.text}</InlineMessage>
</div>
)}
</div>
</div>
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
</Button>
)}
</div>
</div>
</div>
)
}
@ -392,7 +505,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
))}
<Input
aria-label="Custom credit amount"
className="h-8 w-24"
className="w-24 py-[3px]"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
@ -403,6 +516,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
size="sm"
step="0.01"
type="number"
value={amount}
@ -765,6 +879,8 @@ function BillingSettingsContent({
const fixture =
import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
const billingState = useBillingState(!fixture)
const subscriptionState = useSubscriptionState(!fixture)
const billingResult = fixture?.billing ?? billingState.data
@ -778,6 +894,22 @@ function BillingSettingsContent({
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
}
const { paymentRow, refillRow, topupRow } = view
// Gate the plans sub-view on the SAME capability that renders the in-app button
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
// reach a grid of live Choose buttons — it falls back to the overview.
const showPlans = subView === 'plans' && view.status === 'normal' && Boolean(view.plan?.action)
if (showPlans) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingPlansView onBack={() => setSubView('overview')} tiers={view.tiers} />
</SettingsContent>
)
}
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
@ -792,13 +924,32 @@ function BillingSettingsContent({
{view.notice && <NoticeCard notice={view.notice} />}
{view.accountRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Account" />
{view.accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</>
{view.plan && (
<div className="mb-5">
<SectionHeading icon={Package} title="Plan" />
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</div>
)}
{paymentRow && (
<div className="mb-5">
<SectionHeading icon={Lock} title="Payment" />
<AccountRow billing={billing} row={paymentRow} />
</div>
)}
{topupRow && (
<div className="mb-5">
<SectionHeading icon={Plus} title="One-time top-up" />
<AccountRow billing={billing} row={topupRow} />
</div>
)}
{refillRow && (
<div className="mb-5">
<SectionHeading icon={RefreshCw} title="Automatic refill" />
<AccountRow billing={billing} row={refillRow} />
</div>
)}
{view.usageRows.length > 0 && (

View file

@ -0,0 +1,94 @@
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { TierArt } from './tier-art'
import type { BillingPlanTierView } from './use-billing-state'
function PlanCard({ tier }: { tier: BillingPlanTierView }) {
const isCurrent = tier.state === 'current'
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg border p-4',
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
)}
>
<div className="flex min-w-0 items-center gap-3">
<TierArt name={tier.name} />
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}/mo
</div>
</div>
</div>
{tier.creditsDisplay && (
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.creditsDisplay}
</div>
)}
<div className="mt-auto min-w-0 pt-1">
{isCurrent && <Pill tone="primary">Current plan</Pill>}
{tier.state === 'upgrade' && (
<Button onClick={() => tier.action && openExternalLink(tier.action.url)} size="sm" type="button" variant="outline">
{tier.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
{tier.state === 'downgrade' && (
<div className="flex min-w-0 flex-col gap-1.5">
<Button disabled size="sm" type="button" variant="outline">
Downgrade
</Button>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.disabledCaption}
</span>
</div>
)}
</div>
</div>
)
}
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
return (
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label="Back to billing"
className="size-7 p-0 text-(--ui-text-tertiary)"
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<span>Plans</span>
</div>
{tiers.length > 0 ? (
<div className="grid gap-3 @lg:grid-cols-2 @3xl:grid-cols-3">
{tiers.map(tier => (
<PlanCard key={tier.tierId} tier={tier} />
))}
</div>
) : (
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}
</div>
)
}

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveTierArt } from './tier-art'
describe('resolveTierArt', () => {
it('keys art by lowercase tier name, case-insensitively', () => {
for (const name of ['Free', 'starter', 'Plus', 'SUPER', 'ultra']) {
expect(resolveTierArt(name)).not.toBeNull()
}
})
it('maps each named tier to its NAS blend mode', () => {
expect(resolveTierArt('free')?.blend).toBe('screen')
expect(resolveTierArt('plus')?.blend).toBe('screen')
expect(resolveTierArt('super')?.blend).toBe('lighten')
expect(resolveTierArt('ultra')?.blend).toBe('normal')
})
it('returns null for unknown or missing names so the card renders text-only', () => {
expect(resolveTierArt('Mystery')).toBeNull()
expect(resolveTierArt('')).toBeNull()
expect(resolveTierArt(null)).toBeNull()
expect(resolveTierArt(undefined)).toBeNull()
})
})

View file

@ -0,0 +1,69 @@
import automationArt from '@/assets/tiers/feature-automation.webp'
import connectArt from '@/assets/tiers/feature-connect.webp'
import memoryArt from '@/assets/tiers/feature-memory.webp'
import sandboxArt from '@/assets/tiers/feature-sandbox.webp'
import { cn } from '@/lib/utils'
// Reproduces the portal's tier-card hero treatment at thumbnail size: each webp sits
// over a solid Nous-blue well and blends into it. This blue well is the ONLY place
// Nous blue appears in the billing page — everything else stays on the app's own tokens.
const NOUS_BLUE = '#0000f2'
const BLEND_CLASS = {
lighten: 'mix-blend-lighten',
normal: '',
screen: 'mix-blend-screen'
} as const
type TierBlend = keyof typeof BLEND_CLASS
interface TierArtSpec {
blend: TierBlend
src: string
}
// Keyed by lowercase tier NAME, not tier_id: real tier_ids are Prisma cuids that
// differ per environment, while names are stable. `free`/`starter` share the
// entry-tier art. An unknown name resolves to null → the card renders text-only.
const TIER_ART: Record<string, TierArtSpec> = {
free: { blend: 'screen', src: connectArt },
plus: { blend: 'screen', src: memoryArt },
starter: { blend: 'screen', src: connectArt },
super: { blend: 'lighten', src: automationArt },
ultra: { blend: 'normal', src: sandboxArt }
}
export function resolveTierArt(tierName?: null | string): null | TierArtSpec {
if (!tierName) {
return null
}
return TIER_ART[tierName.trim().toLowerCase()] ?? null
}
/**
* Small rounded thumbnail (~40px) rendering the tier art over a Nous-blue well.
* Returns null for unknown tiers so the caller lays out a text-only card without
* reserving empty art space. Imported via vite static imports so the URLs resolve
* under a packaged `file://` origin with webSecurity on.
*/
export function TierArt({ className, name, size = 40 }: { className?: string; name?: null | string; size?: number }) {
const art = resolveTierArt(name)
if (!art) {
return null
}
return (
<div
className={cn('relative shrink-0 overflow-hidden rounded-md', className)}
style={{ background: NOUS_BLUE, height: size, width: size }}
>
<img
alt=""
className={cn('pointer-events-none absolute inset-0 size-full max-w-none object-cover', BLEND_CLASS[art.blend])}
src={art.src}
/>
</div>
)
}

View file

@ -62,15 +62,14 @@ describe('deriveBillingView', () => {
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(
expect(view.topupRow?.description).toBe(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
expect(buyCredits?.chips).toBeUndefined()
expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({
expect(view.topupRow?.chips).toBeUndefined()
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
caption: 'Refill $10 when balance falls below $5',
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap'])
@ -80,15 +79,9 @@ describe('deriveBillingView', () => {
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.paymentRow?.value).toBe('Visa •••• 4242 - subscription card')
expect(view.topupRow?.chips?.map(chip => chip.label)).toEqual(['$25', '$50', '$100'])
expect(view.plan?.link?.url).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
expect(view.usageRows.find(row => row.id === 'subscription_credits')).toMatchObject({
bar: { value: 0.4 },
value: '$40 of $100 left'
@ -107,11 +100,9 @@ describe('deriveBillingView', () => {
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({
expect(view.refillRow?.caption).toContain('Mastercard ••4444')
expect(view.refillRow?.caption).toContain('reconcile')
expect(view.refillRow?.action).toEqual({
label: 'Reconcile ↗',
url: 'https://portal.nousresearch.com/billing'
})
@ -129,17 +120,30 @@ describe('deriveBillingView', () => {
okSubscription(todaySubscriptionState)
)
const autoReload = view.accountRows.find(row => row.id === 'auto_reload')
expect(view.refillRow?.caption).toContain('a different card')
expect(view.refillRow?.caption).not.toContain('null')
expect(view.refillRow?.action?.url).toBe('https://portal.nousresearch.com/billing')
})
expect(autoReload?.caption).toContain('a different card')
expect(autoReload?.caption).not.toContain('null')
expect(autoReload?.action?.url).toBe('https://portal.nousresearch.com/billing')
it('renders the normal enabled auto-refill row when the card is null (no crash)', () => {
// The gateway emits auto_reload.card: null for a missing/unknown-kind card.
const view = deriveBillingView(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } }),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
})
it('keeps buy credit controls visible but disabled when no card is on file', () => {
const fixture = billingDevFixtures['no-card']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const buyCredits = view.accountRows.find(row => row.id === 'buy_credits')
const buyCredits = view.topupRow
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
@ -158,7 +162,9 @@ describe('deriveBillingView', () => {
expect(view.notice).toMatchObject({
title: 'Connect your Nous account'
})
expect(view.accountRows).toEqual([])
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
expect(view.usageRows).toEqual([])
})
@ -170,115 +176,25 @@ describe('deriveBillingView', () => {
expect(view.notice).toMatchObject({
title: 'Billing endpoint unavailable'
})
expect(view.accountRows).toEqual([])
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
})
it('keeps subscription unavailable as a row-level degradation when billing.state succeeds', () => {
it('keeps subscription unavailable as a plan-card degradation with a live portal link', () => {
const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(view.status).toBe('normal')
expect(subscription).toMatchObject({
expect(view.plan).toMatchObject({
caption: 'Subscription details are unavailable; opening the portal is still available.',
value: 'Ultra'
tierName: 'Ultra'
})
expect(view.plan?.action).toBeUndefined()
// The caption promises the portal is still reachable — so the link must exist.
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription'
})
})
it('free with catalog: tier chips render inline and open the portal', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0',
name: 'Free',
tier_id: 'free',
tier_order: 0
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '3000',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '1000',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
}
]
})
)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.')
expect(subscription?.chips).toEqual([
{ disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: `${subscription?.action?.url}&plan=plus` },
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` }
])
})
it('subscriber who can change plans: current tier marked inert, others open the portal', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '1000',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '3000',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(subscription?.chips).toEqual([
{ disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' },
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` }
])
})
it('members and team contexts get no tier chips', () => {
const member = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
})
it('clamps overdrawn subscription credits to $0 and names the overage', () => {
@ -380,6 +296,333 @@ describe('deriveBillingView', () => {
})
})
describe('derivePlanCard (current-plan card)', () => {
it('offers an in-app "View plans" button for a free personal account that can change plans', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'View plans' }, tierName: 'Free' })
expect(view.plan?.link).toBeUndefined()
})
it('offers an in-app "Change plan" button for a personal subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'Change plan' }, price: '$20', tierName: 'Plus' })
expect(view.plan?.link).toBeUndefined()
})
it('gives teams a portal escape hatch and no in-app button', () => {
// todaySubscriptionState is context: 'team'.
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('gives non-changing members a portal link but no in-app button', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('withholds the in-app button (no dead click) and offers the portal link when nothing is actionable', () => {
// A subscriber whose only enabled tier is the one they are already on: the grid
// would show a single inert card, so the "Change plan" button must not appear.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'solo', tier_name: 'Solo' },
tiers: [
{
dollars_per_month_display: '$10',
is_current: true,
is_enabled: true,
monthly_credits: '10',
name: 'Solo',
tier_id: 'solo',
tier_order: 0
}
]
})
)
expect(view.tiers.map(tier => tier.state)).toEqual(['current'])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
it('gives a top-tier subscriber a portal link, not a dead in-app button', () => {
// The subscriber is on the highest enabled tier: the grid holds only downgrades +
// current — nothing to upgrade to — so the card must fall back to the portal link
// rather than open a grid with no enabled action.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'top',
tier_order: 1
}
]
})
)
expect(view.tiers.some(tier => tier.state === 'upgrade')).toBe(false)
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
it('offers only the portal link when the tier catalog is empty', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
tiers: []
})
)
expect(view.tiers).toEqual([])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
})
describe('derivePlanTiers (plans grid)', () => {
it('marks the current tier, upgrades, and disabled downgrades for a subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Plus', 'Super', 'Ultra'])
expect(byName.Free).toMatchObject({
disabledCaption: 'Downgrades are moving in-app — coming soon.',
state: 'downgrade'
})
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
expect('action' in byName.Plus).toBe(false)
expect(byName.Super).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_plus&plan=cltier222super222personal'
},
creditsDisplay: '$110 credits/mo',
state: 'upgrade'
})
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the free/lowest tier current (inert) and every paid tier an upgrade when there is no subscription', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// No "subscribe to Free" — the $0 tier is the current plan, not a choice.
expect(view.tiers.map(tier => tier.state)).toEqual(['current', 'upgrade', 'upgrade', 'upgrade'])
expect('action' in byName.Free).toBe(false)
expect('disabledCaption' in byName.Free).toBe(false)
// No downgrade state can exist without a subscription.
expect(view.tiers.some(tier => tier.state === 'downgrade')).toBe(false)
expect(byName.Plus).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_free&plan=cltier111plus1111personal'
}
})
})
it('still lists a tier whose name has no art mapping (text-only card, no layout break)', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier_free_0000',
tier_order: 0
},
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Mystery',
tier_id: 'cltier_mystery_0000',
tier_order: 5
}
]
})
)
// The unknown-named paid tier still lists (art resolves to null → text-only).
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Mystery'])
expect(view.tiers.find(tier => tier.name === 'Mystery')?.state).toBe('upgrade')
})
it('keeps a grandfathered (is_enabled:false) CURRENT tier inert and orders downgrades against it', () => {
// NAS marks a grandfathered current tier is_enabled:false. It must still appear
// (inert "Current plan") and define the order boundary — lower enabled tiers are
// downgrades, higher ones are Choose.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'legacy_mid', tier_name: 'Legacy' },
tiers: [
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Basic',
tier_id: 'basic',
tier_order: 0
},
{
dollars_per_month_display: '$15',
is_current: true,
is_enabled: false,
monthly_credits: '15',
name: 'Legacy',
tier_id: 'legacy_mid',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '40',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Basic', 'Legacy', 'Ultra'])
expect(byName.Legacy.state).toBe('current')
expect('action' in byName.Legacy).toBe(false)
expect(byName.Basic.state).toBe('downgrade')
expect('action' in byName.Basic).toBe(false)
expect(byName.Ultra).toMatchObject({ action: { label: 'Choose ↗' }, state: 'upgrade' })
})
it('backs Choose URLs with billing.portal_url (org_id + plan intact) when the subscription has no portal_url', () => {
const view = deriveBillingView(
okBilling({ ...todayBillingState, portal_url: 'https://billing.example.com/x' }),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
portal_url: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free0',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus1',
tier_order: 1
}
]
})
)
expect(view.tiers.find(tier => tier.name === 'Plus')).toMatchObject({
action: { url: 'https://billing.example.com/manage-subscription?org_id=sid-5&plan=plus1' }
})
})
it('drops grandfathered (is_enabled: false) tiers from the grid', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$9',
is_current: false,
is_enabled: false,
monthly_credits: '9',
name: 'Legacy',
tier_id: 'legacy',
tier_order: 1
}
]
})
)
expect(view.tiers.map(tier => tier.name)).toEqual(['Plus'])
})
})
describe('buildManageSubscriptionUrl', () => {
it('mirrors the TUI manage-subscription URL construction', () => {
expect(
@ -390,26 +633,27 @@ describe('buildManageSubscriptionUrl', () => {
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('appends the tier as a plan query param when provided', () => {
it('appends plan=<tierId> when a tier is chosen', () => {
expect(
buildManageSubscriptionUrl(
{
org_id: 'org_123',
portal_url: 'https://portal.nousresearch.com/billing'
},
undefined,
'ultra'
{ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' },
null,
'tier_abc'
)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=ultra')
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=tier_abc')
})
it('omits the plan param when no tierId is given', () => {
it('omits the plan param when no tier is given', () => {
expect(
buildManageSubscriptionUrl(
{ org_id: null, portal_url: 'https://portal.nousresearch.com/billing' },
undefined,
undefined
)
).toBe('https://portal.nousresearch.com/manage-subscription')
buildManageSubscriptionUrl({ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' }, null)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('applies org_id + plan to the hard-coded portal fallback when no portal_url resolves', () => {
// Regression: the fallback must be the last-resort ORIGIN, not a bare return that
// silently drops org_id/plan.
expect(buildManageSubscriptionUrl({ org_id: 'org_z', portal_url: null }, null, 'tier_q')).toBe(
'https://portal.nousresearch.com/manage-subscription?org_id=org_z&plan=tier_q'
)
})
})

View file

@ -5,7 +5,12 @@ 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'
import type {
BillingStateResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageModelData
} from './types'
export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
@ -50,7 +55,9 @@ export interface BillingAccountRowView {
caption?: string
chips?: BillingChipView[]
description: string
id: 'auto_reload' | 'buy_credits' | 'payment_method' | 'subscription'
id: 'auto_reload' | 'buy_credits' | 'payment_method'
/** The auto-refill row that edits its amounts in place (canonical-card enabled). */
manageInApp?: true
pill?: {
label: string
tone: 'muted' | 'primary'
@ -60,6 +67,36 @@ export interface BillingAccountRowView {
value?: string
}
/**
* The current-plan summary that replaces the old subscription row. Carries EITHER
* one in-app `action` (View plans / Change plan) OR a portal `link` ("Adjust plan
* "), never both a discriminated pair so consumers don't guard for the impossible
* "both present" / "neither present" cases.
*/
export type BillingPlanCardView = {
caption: string
price?: string
tierName: string
} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } })
interface BillingPlanTierBase {
creditsDisplay?: string
name: string
priceDisplay: string
tierId: string
}
/**
* One card in the `bview=plans` grid, discriminated by `state`: an `upgrade` always
* carries its portal `action`; a `downgrade` always carries a `disabledCaption`
* (ticket 11 wires these in-app); `current` is inert. The union lets consumers read
* `action` / `disabledCaption` without defensive `?.`.
*/
export type BillingPlanTierView =
| (BillingPlanTierBase & { state: 'current' })
| (BillingPlanTierBase & { disabledCaption: string; state: 'downgrade' })
| (BillingPlanTierBase & { action: { label: string; url: string }; state: 'upgrade' })
export interface BillingUsageRowView {
bar?: {
label: string
@ -75,10 +112,19 @@ export interface BillingUsageRowView {
}
export interface BillingView {
accountRows: BillingAccountRowView[]
notice?: BillingNoticeView
/** Payment section row. Absent outside the normal (logged-in) state. */
paymentRow?: BillingAccountRowView
/** Current-plan card (Plan section). Absent until billing.state resolves. */
plan?: BillingPlanCardView
/** Automatic-refill section row. */
refillRow?: BillingAccountRowView
status: 'loading' | 'logged_out' | 'normal' | 'refusal'
summary: BillingSummaryItemView[]
/** Live tier catalog for the plans sub-view (empty when unavailable). */
tiers: BillingPlanTierView[]
/** One-time top-up section row. */
topupRow?: BillingAccountRowView
usageRows: BillingUsageRowView[]
}
@ -110,19 +156,19 @@ export function deriveBillingView(
): BillingView {
if (!stateResult) {
return {
accountRows: [],
status: 'loading',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
if (!stateResult.ok) {
return {
accountRows: [],
notice: refusalNotice(stateResult.refusal),
status: 'refusal',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
@ -132,7 +178,6 @@ export function deriveBillingView(
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.',
@ -140,12 +185,22 @@ export function deriveBillingView(
},
status: 'logged_out',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
// One "can change plans in-app" verdict, shared by the plan card (button vs portal
// link) and the grid (whether upgrade tiles are actionable) so the invariant lives
// in one place.
const capable = plansCapable(subscription, subscriptionResult)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable)
return {
accountRows: deriveAccountRows(billing, subscription, subscriptionResult),
notice: undefined,
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable),
refillRow: autoReloadRow(billing),
status: 'normal',
summary: [
{ label: 'Balance', value: displayBalance(billing) },
@ -156,6 +211,8 @@ export function deriveBillingView(
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
}
],
tiers,
topupRow: buyCreditsRow(billing),
usageRows: deriveUsageRows(billing, subscription)
}
}
@ -163,9 +220,14 @@ export function deriveBillingView(
export function buildManageSubscriptionUrl(
subscription?: null | Pick<SubscriptionStateResponse, 'org_id' | 'portal_url'>,
fallbackPortalUrl?: null | string,
tierId?: string
// Optional tier to pre-select on the portal, appended as `plan=<tierId>`
// (validated server-side by the NAS reader, draft #748).
tierId?: null | string
): string {
const portalUrls = [subscription?.portal_url, fallbackPortalUrl].filter(
// The hard-coded portal is the LAST-RESORT origin, not a bare early return:
// org_id / plan must still be applied to it so a null portal_url never silently
// strips the params that route the user to the right org + pre-selected tier.
const portalUrls = [subscription?.portal_url, fallbackPortalUrl, FALLBACK_PORTAL_BILLING_URL].filter(
(url): url is string => typeof url === 'string' && url.length > 0
)
@ -243,17 +305,153 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
}
}
function deriveAccountRows(
// The active tier from the UNFILTERED catalog — a grandfathered current tier is
// is_enabled:false, so it must still resolve here (by is_current or matching id).
function findCurrentTier(subscription: null | SubscriptionStateResponse): SubscriptionTierOption | undefined {
const current = subscription?.current
return subscription?.tiers?.find(tier => tier.is_current || tier.tier_id === current?.tier_id)
}
// Whether this account can change plans in-app: a personal (non-team) subscription
// the server says the user can change, whose payload actually loaded.
function plansCapable(
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined
): boolean {
if (!subscription || (subscriptionResult && !subscriptionResult.ok)) {
return false
}
return subscription.context !== 'team' && Boolean(subscription.can_change_plan)
}
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
}
/**
* The current-plan card. It offers the in-app "View plans" / "Change plan" button
* ONLY when the account is plans-capable AND the grid has an actual UPGRADE to offer
* a top-tier subscriber (only downgrades / current below them) would otherwise open
* a grid with nothing to do. In every no-button case (teams, non-changers, refused
* subscription, top tier, empty catalog) the card ALWAYS carries the portal
* escape-hatch link so the user is never stranded on an info-only card.
*/
function derivePlanCard(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
): BillingAccountRowView[] {
return [
paymentMethodRow(billing),
subscriptionRow(billing, subscription, subscriptionResult),
buyCreditsRow(billing),
autoReloadRow(billing)
]
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
tiers: BillingPlanTierView[],
capable: boolean
): BillingPlanCardView {
const current = subscription?.current
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
// still shows its price.
const price = findCurrentTier(subscription)?.dollars_per_month_display
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
const caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
// Actionable = a tile the user can act on. At ticket 09 only upgrades carry an
// `action` (downgrades are inert), so "has an action" ⟺ an upgrade exists. (The
// discriminated union means `'action' in tier` rather than `tier.action != null`.)
const hasActionableTier = tiers.some(tier => 'action' in tier)
if (capable && hasActionableTier) {
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, price, tierName }
}
return {
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
label: 'Adjust plan ↗',
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
price,
tierName
}
}
/**
* The plans-grid catalog. Each card's state depends on its order relative to the
* current tier: current = inert marker; higher = "Choose ↗" opening the portal with
* the tier pre-selected; lower = disabled with a caption noting downgrades are
* moving in-app (ticket 11 wires the gateway pending-change flow). With no active
* subscription the lowest-order ($0 / free) tier stands in as the current plan, so
* there is no "subscribe to Free" upgrade and no downgrade state.
*
* Empty unless `capable`: only a plans-capable account gets actionable tiles, and the
* plan card / deep-link gate on the same verdict so the grid never mints an
* upgrade action nobody may take. `fallbackPortalUrl` (billing.portal_url) backs the
* Choose URLs when the subscription payload has no portal_url, so org_id + plan are
* never dropped.
*/
function derivePlanTiers(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl: null | string,
capable: boolean
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
}
const allTiers = subscription.tiers ?? []
const current = subscription.current
const explicitCurrent = findCurrentTier(subscription)
// The grid shows the enabled catalog plus the grandfathered current tier (so it
// still renders as the inert "Current plan" card), sorted low→high.
const gridTiers = allTiers
.filter(tier => tier.is_enabled || tier.tier_id === explicitCurrent?.tier_id)
.slice()
.sort((a, b) => a.tier_order - b.tier_order)
if (gridTiers.length === 0) {
return []
}
// No active subscription → the lowest-order ($0 / free) tier stands in as the
// current plan: inert, never a "subscribe to Free" upgrade, and (being lowest)
// never leaving room for a downgrade.
const currentTier = explicitCurrent ?? (current == null ? gridTiers[0] : undefined)
const currentOrder = currentTier?.tier_order
const manageBase = subscription.portal_url ?? fallbackPortalUrl
return gridTiers.map((tier): BillingPlanTierView => {
const base: BillingPlanTierBase = {
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
name: tier.name,
priceDisplay: tier.dollars_per_month_display,
tierId: tier.tier_id
}
if (currentTier && tier.tier_id === currentTier.tier_id) {
return { ...base, state: 'current' }
}
// Downgrade = strictly below the current tier's order.
if (currentOrder != null && tier.tier_order < currentOrder) {
return { ...base, disabledCaption: 'Downgrades are moving in-app — coming soon.', state: 'downgrade' }
}
return {
...base,
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
state: 'upgrade'
}
})
}
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
@ -279,71 +477,6 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
}
}
/**
* Tier catalog as chips for accounts that can change plans; the current plan is
* inert, every other opens the portal where the change/start happens, deep-linked
* to that tier via `?plan=`.
*/
function subscriptionTierChips(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl?: null | string
): BillingChipView[] | undefined {
// Teams have no personal subscription to sell into.
if (!subscription?.can_change_plan || subscription.context === 'team') {
return undefined
}
const tiers = (subscription.tiers ?? [])
.filter(tier => tier.is_enabled && tier.tier_order > 0)
.sort((a, b) => a.tier_order - b.tier_order)
if (tiers.length === 0) {
return undefined
}
return tiers.map(tier => {
// Monthly credits are dollars; NAS sends a bare decimal string.
const credits = Number((tier.monthly_credits ?? '').replace(/,/g, ''))
const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : ''
const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`
return tier.is_current
? { disabled: true, label: `${label}` }
: { disabled: false, label, url: buildManageSubscriptionUrl(subscription, fallbackPortalUrl, tier.tier_id) }
})
}
function subscriptionRow(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
): BillingAccountRowView {
const fallbackPortalUrl = subscription?.portal_url ?? billing.portal_url
const manageUrl = buildManageSubscriptionUrl(subscription, fallbackPortalUrl)
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
const chips = subscriptionTierChips(subscription, fallbackPortalUrl)
return {
action: { label: 'Adjust plan ↗', url: manageUrl },
caption: unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: `Renews ${renewal}`,
chips,
description:
!current && chips
? 'Paid models need a subscription — pick a plan to start it on the portal.'
: '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 {
@ -355,7 +488,7 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
portalUrl: billing.portal_url ?? undefined
}).message,
id: 'buy_credits',
title: 'Buy credits'
title: 'Buy credits now'
}
}
@ -365,19 +498,24 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
return {
description: disabledReason,
id: 'buy_credits',
title: 'Buy credits'
title: 'Buy credits now'
}
}
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.',
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits'
title: 'Buy credits now'
}
}
// The generic first sentence shared by the off / absent / divergent states,
// where the concrete amounts aren't the headline. The configured state overrides
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const autoReload = billing.auto_reload
@ -385,24 +523,26 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
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.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
description: 'Keep your balance topped up when it drops below your threshold.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
if (autoReload.card.kind === 'distinct') {
// A null card (gateway emits it for a missing/unknown-kind card) falls through to
// the default enabled path below — the same treatment as a canonical card.
if (autoReload.card?.kind === 'distinct') {
const { brand, last4 } = autoReload.card
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
@ -410,22 +550,27 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
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.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Enabled', tone: 'primary' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
const reloadTo = autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
return {
action: { label: 'Manage' },
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.',
// Numbers live in the first sentence (spec §8); the swap region below carries
// the editable fields, so no redundant caption here.
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
id: 'auto_reload',
// The only row that edits in place — AutoReloadRow keys its swap layout off this
// flag rather than sniffing the action label.
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
@ -519,8 +664,7 @@ function displayPlan(subscription: null | SubscriptionStateResponse, usage?: Usa
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
const price = findCurrentTier(subscription)?.dollars_per_month_display
return price ? `${tier} · ${price}/mo` : tier
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -108,6 +108,9 @@ export interface BillingMonthlyCap {
}
export interface BillingAutoReload {
// The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind
// card, and _serialize_billing_state emits `card: null` — so the wire really can
// carry null. Consumers must keep a null branch (treat it like the canonical card).
card:
| { kind: 'canonical' }
| {
@ -117,6 +120,7 @@ export interface BillingAutoReload {
last4: string | null
}
| { kind: 'none' }
| null
enabled: boolean
reload_to_display: string
reload_to_usd: string | null

View file

@ -685,7 +685,7 @@ function StepUpScreen({
function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const ar = s.auto_reload
const enabled = Boolean(ar?.enabled)
const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null
const distinctCard = ar?.card?.kind === 'distinct' ? ar.card : null
const distinctCardName = distinctCard
? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') ||