From 94c944363c10405e3544b0aeeaac1d00f0b85a54 Mon Sep 17 00:00:00 2001
From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
Date: Tue, 21 Jul 2026 12:20:59 +0530
Subject: [PATCH] feat(tui): show the plan catalog in /subscription on Free
(#68357)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(tui): show the plan catalog in /subscription on Free
The server returns the tier list even with no subscription, but the
overlay hid the picker behind can_change_plan && !isFree, so a Free
account got only "Start a subscription" with no idea what the plans
cost. Now:
- Overview on Free offers "Choose a plan" whenever the catalog has
enabled paid tiers.
- The picker on Free lists each plan as name · price · monthly credits
(no upgrade/downgrade hints — there is nothing to move from), and
picking one opens the portal, where starting a subscription actually
happens (card capture + checkout live there; the upgrade RPC requires
an existing subscription).
- Paid-plan behavior (preview → confirm → apply) is unchanged.
* refactor(tui): compute the picker row suffix once
Review feedback: the isFree fork duplicated the label template and run
handler; only the suffix differs.
* fix(tui): arm the busy guard before the Free portal handoff
Adversarial review: the Free branch returned before setting busyRef, so
a double-Enter could open the portal twice; and the picker narrated a
handoff that openManageLink already narrates (duplicate on success,
contradictory on failure). Guard first, let the helper do the talking.
* fix(tui): monthly credits are dollars — label them as such
The Free picker showed "1000 credits/mo" for what is $1,000 of monthly
credit — render "$1,000 credits/mo" (grouped, dollar-signed).
* feat(tui): render the Free-plan catalog inline in the /subscription overview
Sid ruling: the upsell belongs where the user already is — no
intermediate "Choose a plan" hop. On Free the overview lists each paid
plan (name · $/mo · $credits/mo) as a pickable row; picking opens the
portal (openManageLink narrates). The generic "Start a subscription"
row survives only when the catalog is empty. The picker reverts to its
original change-only form (Free never reaches it).
* feat(desktop): tier catalog chips on the Subscription row
Desktop parity with the TUI inline catalog (Sid ruling): accounts that
can act see the plans where they already are — Free gets the upsell
list (every chip opens the portal), a subscriber sees all tiers with
the current one marked inert. Members and team contexts see no chips.
Chips learn an optional url (portal handoff) in the shared row model.
* chore(tui): fixture harness mirrors the live tier catalog
The dev screenshot fixtures showed invented plans ($50 Super / $99
Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200
with $22/$110/$220 monthly credits) so fixture renders cannot be
mistaken for product truth. The overlay itself always reads tiers from
the subscription API.
* chore: trim narration comments
---
.../src/app/settings/billing/index.tsx | 9 +-
.../billing/use-billing-state.test.ts | 94 +++++++++++++++++++
.../app/settings/billing/use-billing-state.ts | 40 +++++++-
ui-tui/scripts/billing-fixtures.tsx | 8 +-
.../__tests__/subscriptionOverlay.test.tsx | 41 ++++++++
ui-tui/src/components/subscriptionOverlay.tsx | 31 +++++-
6 files changed, 217 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx
index 94ffda3b7c..a564047575 100644
--- a/apps/desktop/src/app/settings/billing/index.tsx
+++ b/apps/desktop/src/app/settings/billing/index.tsx
@@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun
{row.pill && {row.pill.label} }
{row.secondaryPill && {row.secondaryPill} }
{row.chips?.map(chip => (
-
+ openExternal(chip.url) : undefined}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
{chip.label}
))}
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 f87d9f4fe1..8a71b51f4e 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
@@ -184,6 +184,100 @@ describe('deriveBillingView', () => {
})
})
+ 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 },
+ { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url }
+ ])
+ })
+
+ 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 }
+ ])
+ })
+
+ 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', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
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 870af16367..6fda6cc730 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts
@@ -41,6 +41,8 @@ export interface BillingRowActionView {
export interface BillingChipView {
disabled: boolean
label: string
+ /** When set, clicking the chip opens this URL externally. */
+ url?: string
}
export interface BillingAccountRowView {
@@ -272,6 +274,37 @@ 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.
+ */
+function subscriptionTierChips(
+ subscription: null | SubscriptionStateResponse,
+ manageUrl: 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: manageUrl }
+ })
+}
+
function subscriptionRow(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
@@ -283,13 +316,18 @@ function subscriptionRow(
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, manageUrl)
return {
action: { label: 'Adjust plan ↗', url: manageUrl },
caption: unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: `Renews ${renewal}`,
- description: 'Review your plan and change it from the billing portal.',
+ 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',
diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx
index 7f6edac012..53bcbeb9a8 100644
--- a/ui-tui/scripts/billing-fixtures.tsx
+++ b/ui-tui/scripts/billing-fixtures.tsx
@@ -42,11 +42,13 @@ const tier = (o: Partial = {}): SubscriptionTierOption =
...o
})
+// Mirrors the live portal catalog so fixtures don't drift; the real overlay
+// reads tiers from GET /api/billing/subscription, never from here.
const TIERS = {
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
- plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }),
- super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }),
- ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' })
+ plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '22' }),
+ super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$100', monthly_credits: '110' }),
+ ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$200', monthly_credits: '220' })
}
const tierList = (currentId?: string): SubscriptionTierOption[] =>
diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx
index 0569f64164..4a5a5ed21a 100644
--- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx
+++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx
@@ -149,6 +149,39 @@ describe('SubscriptionOverlay — overview', () => {
expect(out.toLowerCase()).not.toContain('credits')
})
+ it('free with catalog: plans render inline; the generic portal row disappears', () => {
+ const out = render(overlay(freeWithCatalog()))
+
+ expect(out).toContain('Plus · $20/mo · $1,000 credits/mo')
+ expect(out).toContain('Ultra · $40/mo · $3,000 credits/mo')
+ expect(out).not.toContain('upgrade') // a start, not a move
+ expect(out).not.toContain('$0/mo') // free tier is not an option
+ expect(out).not.toContain('Choose a plan')
+ expect(out).not.toContain('Start a subscription')
+ })
+
+ it('free with catalog: picking a plan opens the portal once, even on double-Enter', async () => {
+ const openManageLink = vi.fn(() => Promise.resolve(true))
+ const preview = vi.fn(() => Promise.resolve(null))
+ const sys = vi.fn()
+
+ const mounted = mount({
+ ctx: { ...ctx, openManageLink, preview, sys } as SubscriptionOverlayState['ctx'],
+ screen: 'overview',
+ state: freeWithCatalog()
+ })
+
+ inputHarness.handler?.('', { return: true }) // first row = Plus
+ inputHarness.handler?.('', { return: true })
+ await vi.waitFor(() => expect(openManageLink).toHaveBeenCalled())
+ mounted.cleanup()
+
+ expect(openManageLink).toHaveBeenCalledTimes(1)
+ expect(preview).not.toHaveBeenCalled()
+ // openManageLink narrates the handoff itself.
+ expect(sys).not.toHaveBeenCalled()
+ })
+
it('subscriber: status line + plan bar + top-up bar, no "credits"', () => {
const out = render(
overlay(
@@ -318,6 +351,14 @@ const at = (
extra: Partial = {}
): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra })
+// Free account (no current sub) where NAS still returns the tier catalog.
+const freeWithCatalog = (): SubscriptionStateResponse =>
+ state({
+ current: null,
+ tiers: TIERS.map(tier => ({ ...tier, is_current: false })),
+ usage: { available: true, plan_name: null, status: 'free' }
+ })
+
describe('SubscriptionOverlay — overview actions', () => {
it('admin subscriber: offers Change plan + Cancel subscription', () => {
const out = render(overlay(subscriber()))
diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx
index 3a75ddab21..5e0ae4af48 100644
--- a/ui-tui/src/components/subscriptionOverlay.tsx
+++ b/ui-tui/src/components/subscriptionOverlay.tsx
@@ -374,6 +374,11 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
// Admin/owner on a personal paid plan can change it in-terminal; otherwise the
// portal enforces who can act (members) / starting a new sub needs a card.
const canChange = s.can_change_plan && !isFree
+ // On Free the catalog renders inline; picking a plan hands off to the portal,
+ // where starting a subscription needs card capture + checkout.
+ const freePlans = isFree
+ ? s.tiers.filter(tier => tier.is_enabled && tier.tier_order > 0).sort((a, b) => a.tier_order - b.tier_order)
+ : []
// Guard the async resume so a double-press cannot fire two DELETEs mid-await.
const busyRef = useRef(false)
@@ -422,7 +427,31 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
}
}
- rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage })
+ for (const tier of freePlans) {
+ // NAS sends a bare decimal string; tolerate pre-grouped ("1,000") too.
+ const credits = Number((tier.monthly_credits ?? '').replace(/,/g, ''))
+ const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : ''
+
+ rows.push({
+ label: `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`,
+ run: () => {
+ if (busyRef.current) {
+ return
+ }
+
+ busyRef.current = true
+ void ctx.openManageLink()
+ onClose()
+ }
+ })
+ }
+
+ // The inline plan rows are the subscribe path; only a catalog-less free state
+ // still needs the generic portal row.
+ if (!isFree || freePlans.length === 0) {
+ rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage })
+ }
+
rows.push({ label: 'Close', run: onClose })
const sel = useMenu(rows, onClose)