feat(tui): show the plan catalog in /subscription on Free (#68357)

* 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
This commit is contained in:
Siddharth Balyan
2026-07-21 12:20:59 +05:30
committed by GitHub
parent b0da653ac8
commit 94c944363c
6 changed files with 217 additions and 6 deletions
@@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button disabled={chip.disabled} key={chip.label} size="sm" type="button" variant="outline">
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}
@@ -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),
@@ -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',
+5 -3
View File
@@ -42,11 +42,13 @@ const tier = (o: Partial<SubscriptionTierOption> = {}): 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[] =>
@@ -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> = {}
): 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()))
+30 -1
View File
@@ -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)