diff --git a/apps/desktop/src/app/settings/billing/dev-fixtures.ts b/apps/desktop/src/app/settings/billing/dev-fixtures.ts index 8f69cf687a1e..d5ed680586f2 100644 --- a/apps/desktop/src/app/settings/billing/dev-fixtures.ts +++ b/apps/desktop/src/app/settings/billing/dev-fixtures.ts @@ -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> = {} @@ -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 => ({ data, ok: true }) const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ @@ -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, diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 29b8f95e82ab..ad12438cbbcb 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -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( - - - + + + + + ) 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 () => { diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index a56404757545..814585c49d8e 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -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 (
{row.value && ( @@ -105,16 +117,16 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun {chip.label} ))} - {row.action && ( + {action && ( )}
@@ -147,6 +159,46 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil ) } +function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) { + return ( +
+
+
+ +
+
+ + {plan.tierName} + + {plan.price && ( + + {plan.price}/mo + + )} +
+
+ {plan.caption} +
+
+
+
+ {plan.action && ( + + )} + {plan.link && ( + + )} +
+
+
+ ) +} + 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) const [refusal, setRefusal] = useState(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 ? ( -
-
- - -
- {validation.error && ( -
{validation.error}
- )} -
- - - -
- {confirmDisable && ( -
- Turn off auto-refill? - - -
- )} - - {message && {message.text}} -
- ) : ( - <> - {row.caption ? ( -
- {row.caption} -
- ) : null} - - {message && {message.text}} - - ) + // Read-only states (off / divergent card) keep the original ListRow shape. + if (!editable) { + return ( + } + below={ + <> + {row.caption ? ( +
+ {row.caption} +
+ ) : null} + + {message && {message.text}} + + } + 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 ( - { - resetFeedback() - setEditing(true) - } - } - row={row} - /> - } - below={below} - description={row.description} - key={row.id} - title={row.title} - /> +
+
+
+
+ {row.title} +
+
+ {row.description} +
+
+ {/* EDIT layer — always in layout (reserves exact height); hidden until editing. */} +
+
+ + +
+ {/* Pre-allocated error line — occupies height whether or not shown. */} +
+ {showErrors && validation.error ? validation.error : ''} +
+ {confirmDisable ? ( +
+ Turn off auto-refill? + + +
+ ) : ( + + )} + {/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */} + +
+ {/* VIEW layer — success feedback overlaid in the same cell when not editing. */} + {!editing && message && ( +
+ {message.text} +
+ )} +
+
+ {/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */} +
+ {row.pill && {row.pill.label}} + {editing ? ( + <> + + + + ) : ( + + )} +
+
+
) } @@ -392,7 +505,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B ))} ('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 ( + + + setSubView('overview')} tiers={view.tiers} /> + + ) + } + return ( @@ -792,13 +924,32 @@ function BillingSettingsContent({ {view.notice && } - {view.accountRows.length > 0 && ( - <> - - {view.accountRows.map(row => ( - - ))} - + {view.plan && ( +
+ + setSubView('plans')} plan={view.plan} /> +
+ )} + + {paymentRow && ( +
+ + +
+ )} + + {topupRow && ( +
+ + +
+ )} + + {refillRow && ( +
+ + +
)} {view.usageRows.length > 0 && ( diff --git a/apps/desktop/src/app/settings/billing/plans-view.tsx b/apps/desktop/src/app/settings/billing/plans-view.tsx new file mode 100644 index 000000000000..30ca37accf9c --- /dev/null +++ b/apps/desktop/src/app/settings/billing/plans-view.tsx @@ -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 ( +
+
+ +
+
+ {tier.name} +
+
+ {tier.priceDisplay}/mo +
+
+
+ + {tier.creditsDisplay && ( +
+ {tier.creditsDisplay} +
+ )} + +
+ {isCurrent && Current plan} + + {tier.state === 'upgrade' && ( + + )} + + {tier.state === 'downgrade' && ( +
+ + + {tier.disabledCaption} + +
+ )} +
+
+ ) +} + +export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) { + return ( +
+
+ + Plans +
+ + {tiers.length > 0 ? ( +
+ {tiers.map(tier => ( + + ))} +
+ ) : ( +
+ No plans are available to change to right now. +
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/tier-art.test.ts b/apps/desktop/src/app/settings/billing/tier-art.test.ts new file mode 100644 index 000000000000..60d8d4291bab --- /dev/null +++ b/apps/desktop/src/app/settings/billing/tier-art.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/src/app/settings/billing/tier-art.tsx b/apps/desktop/src/app/settings/billing/tier-art.tsx new file mode 100644 index 000000000000..9d34a048a834 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/tier-art.tsx @@ -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 = { + 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 ( +
+ +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index 90bb728990d9..710ef3ed8c13 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -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= 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' + ) }) }) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 8fbc481b1b8f..d55af57c7fcf 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -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, fallbackPortalUrl?: null | string, - tierId?: string + // Optional tier to pre-select on the portal, appended as `plan=` + // (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 | 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 -): BillingAccountRowView[] { - return [ - paymentMethodRow(billing), - subscriptionRow(billing, subscription, subscriptionResult), - buyCreditsRow(billing), - autoReloadRow(billing) - ] + subscriptionResult: BillingResult | 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 -): 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 } diff --git a/apps/desktop/src/assets/tiers/feature-automation.webp b/apps/desktop/src/assets/tiers/feature-automation.webp new file mode 100644 index 000000000000..7db7c551bbdc Binary files /dev/null and b/apps/desktop/src/assets/tiers/feature-automation.webp differ diff --git a/apps/desktop/src/assets/tiers/feature-connect.webp b/apps/desktop/src/assets/tiers/feature-connect.webp new file mode 100644 index 000000000000..36941196bbdb Binary files /dev/null and b/apps/desktop/src/assets/tiers/feature-connect.webp differ diff --git a/apps/desktop/src/assets/tiers/feature-memory.webp b/apps/desktop/src/assets/tiers/feature-memory.webp new file mode 100644 index 000000000000..b70bf6a2476e Binary files /dev/null and b/apps/desktop/src/assets/tiers/feature-memory.webp differ diff --git a/apps/desktop/src/assets/tiers/feature-sandbox.webp b/apps/desktop/src/assets/tiers/feature-sandbox.webp new file mode 100644 index 000000000000..08a9d6e3f26d Binary files /dev/null and b/apps/desktop/src/assets/tiers/feature-sandbox.webp differ diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index 33004746c81b..d8d7415c7bb3 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -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 diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index c5577a5c6cf1..52cf5529c550 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -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(' ') ||