mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
5c4c419307
commit
7c68b4eae1
14 changed files with 1368 additions and 374 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
94
apps/desktop/src/app/settings/billing/plans-view.tsx
Normal file
94
apps/desktop/src/app/settings/billing/plans-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/desktop/src/app/settings/billing/tier-art.test.ts
Normal file
25
apps/desktop/src/app/settings/billing/tier-art.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
69
apps/desktop/src/app/settings/billing/tier-art.tsx
Normal file
69
apps/desktop/src/app/settings/billing/tier-art.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
BIN
apps/desktop/src/assets/tiers/feature-automation.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-automation.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
apps/desktop/src/assets/tiers/feature-connect.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-connect.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
apps/desktop/src/assets/tiers/feature-memory.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-memory.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
apps/desktop/src/assets/tiers/feature-sandbox.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-sandbox.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(' ') ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue