fix(desktop): make no-card disabled billing actions explain themselves

A cluster of dead preset/amount/Buy controls and an action-less "turn it on
from the portal" caption never said WHY they were inert. Each blocked row now
states the blocker and carries the one action that fixes it:

- Buy credits now (no card): the disabled controls are replaced by
  "No card on file" + an Add card portal action. Policy blockers (role /
  CLI billing off / remote spending off) outrank the missing card, since
  adding a card would not unlock buying.
- Refill when low (no card, off): "Needs a card on file before it can be
  turned on." + Add card. An enabled config whose card disappeared keeps
  its Manage row so it can still be turned off.
- Refill when low (card, off): the portal caption now links to the portal
  it names (Turn on).
- Notice/link buttons dropped the literal "↗" from their labels — every
  render site already appends the ExternalLink icon, so labels showed a
  doubled arrow.
This commit is contained in:
alt-glitch 2026-07-23 08:44:21 +05:30
parent 305a3c7424
commit 26571fa940
3 changed files with 137 additions and 48 deletions

View file

@ -112,7 +112,7 @@ describe('BillingSettings', () => {
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false)
})
it('disables buy controls when no card is on file', async () => {
it('replaces the buy and auto-refill controls with reasons and Add card actions when no card is on file', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
@ -122,16 +122,18 @@ describe('BillingSettings', () => {
// No card → the payment row collapses to a single "Add payment method" link.
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /^Buy$/ }))
// The dead control cluster is gone entirely — no disabled presets, amount
// field, or Buy button that can't explain themselves.
expect(screen.queryByRole('button', { name: '$25' })).toBeNull()
expect(screen.queryByRole('spinbutton', { name: 'Custom credit amount' })).toBeNull()
expect(screen.queryByRole('button', { name: /^Buy$/ })).toBeNull()
expect(apiMocks.charge).not.toHaveBeenCalled()
// Each blocked row states the reason and offers the fix: the warn banner plus
// the buy row and the auto-refill row each carry an Add card action.
expect(screen.getByText('No card on file')).toBeTruthy()
expect(screen.getByText('Needs a card on file before it can be turned on.')).toBeTruthy()
expect(screen.getAllByRole('button', { name: /Add card/ })).toHaveLength(3)
})
it('saves enabled auto-refill edits and refreshes billing state', async () => {
@ -542,14 +544,17 @@ describe('BillingSettings', () => {
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})
it('keeps disabled auto-refill portal-only with no enable control', async () => {
it('keeps disabled auto-refill portal-only, now with a live Turn on link', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0)
expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy()
expect(screen.getByText('Turn on auto-refill from the portal.')).toBeTruthy()
// The caption names the portal, so the row links to it — but still no in-app
// enable / Manage control.
expect(screen.getByRole('button', { name: /Turn on/ })).toBeTruthy()
expect(screen.queryByRole('button', { name: /enable/i })).toBeNull()
expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull()
})
@ -670,7 +675,8 @@ describe('BillingSettings', () => {
expect(
screen.getByText('Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.')
).toBeTruthy()
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
// Banner + buy row + auto-refill row each carry the fix.
expect(screen.getAllByRole('button', { name: /Add card/ }).length).toBeGreaterThan(0)
})
it('does not show the no-card notice when a card is on file', async () => {

View file

@ -103,7 +103,7 @@ describe('deriveBillingView', () => {
expect(view.refillRow?.caption).toContain('Mastercard ••4444')
expect(view.refillRow?.caption).toContain('reconcile')
expect(view.refillRow?.action).toEqual({
label: 'Reconcile',
label: 'Reconcile',
url: 'https://portal.nousresearch.com/billing'
})
})
@ -140,23 +140,86 @@ describe('deriveBillingView', () => {
})
})
it('keeps buy credit controls visible but disabled when no card is on file', () => {
it('replaces the buy controls with the reason and an Add card action when no card is on file', () => {
const fixture = billingDevFixtures['no-card']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const buyCredits = view.topupRow
// Dead disabled controls can't explain themselves — the row states the blocker
// and offers the one action that fixes it instead.
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
// The no-card blocker is explained once by the page-level notice, not
// duplicated (emoji and all) into the row description.
description: 'A single charge on your card, added to your balance today.'
action: { label: 'Add card', url: 'https://portal.nousresearch.com/billing' },
description: 'A single charge on your card, added to your balance today.',
value: 'No card on file'
})
expect(buyCredits?.description).not.toContain('💳')
expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true])
expect(buyCredits?.chips).toBeUndefined()
expect(buyCredits?.action?.disabled).toBeUndefined()
// The page still leads with the warn banner naming the blocker + fix.
expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' })
})
it('gives the off auto-refill row the same no-card reason and Add card action', () => {
const fixture = billingDevFixtures['no-card']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.refillRow).toMatchObject({
action: { label: 'Add card', url: 'https://portal.nousresearch.com/billing' },
caption: 'Needs a card on file before it can be turned on.',
pill: { label: 'Off', tone: 'muted' }
})
expect(view.refillRow?.manageInApp).toBeUndefined()
})
it('shows the no-card auto-refill reason with an em-dash pill when auto_reload is absent', () => {
const view = deriveBillingView(
okBilling({ ...postTrainBillingState, auto_reload: null, card: null }),
okSubscription(postTrainSubscriptionState)
)
expect(view.refillRow).toMatchObject({
action: { label: 'Add card' },
caption: 'Needs a card on file before it can be turned on.',
pill: { label: '—', tone: 'muted' }
})
})
it('keeps an enabled auto-refill config manageable even after the card disappears', () => {
const view = deriveBillingView(
okBilling({
...postTrainBillingState,
auto_reload: { ...postTrainBillingState.auto_reload, enabled: true },
card: null
}),
okSubscription(postTrainSubscriptionState)
)
// The user must still be able to turn it off in-app — no Add card hijack here.
expect(view.refillRow).toMatchObject({ action: { label: 'Manage' }, manageInApp: true })
})
it('lets a policy blocker outrank the missing card on the buy row', () => {
const view = deriveBillingView(
okBilling({ ...postTrainBillingState, can_charge: false, card: null }),
okSubscription(postTrainSubscriptionState)
)
// Adding a card would not unlock buying while remote spending is off, so the
// row names the policy reason and shows no card call-to-action.
expect(view.topupRow?.action).toBeUndefined()
expect(view.topupRow?.value).toBeUndefined()
expect(view.topupRow?.description).not.toContain('single charge')
})
it('links the off-with-card auto-refill row to the portal it names', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
expect(view.refillRow).toMatchObject({
action: { label: 'Turn on', url: 'https://portal.nousresearch.com/billing' },
caption: 'Turn on auto-refill from the portal.',
pill: { label: 'Off', tone: 'muted' }
})
})
it('derives a calm logged-out card with no account or usage rows', () => {
const view = deriveBillingView(okBilling(loggedOutBillingState), okSubscription(loggedOutSubscriptionState))
@ -195,7 +258,7 @@ describe('deriveBillingView', () => {
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',
label: 'Adjust plan',
url: 'https://portal.nousresearch.com/manage-subscription'
})
})
@ -322,7 +385,7 @@ describe('derivePlanCard (current-plan card)', () => {
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan',
label: 'Adjust plan',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
@ -335,7 +398,7 @@ describe('derivePlanCard (current-plan card)', () => {
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan',
label: 'Adjust plan',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
@ -366,7 +429,7 @@ describe('derivePlanCard (current-plan card)', () => {
expect(view.tiers.map(tier => tier.state)).toEqual(['current'])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan')
expect(view.plan?.link?.label).toBe('Adjust plan')
})
it('gives a top-tier subscriber a portal link, not a dead in-app button', () => {
@ -495,7 +558,7 @@ describe('derivePlanCard (current-plan card)', () => {
expect(view.tiers).toEqual([])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan')
expect(view.plan?.link?.label).toBe('Adjust plan')
})
})
@ -636,7 +699,7 @@ describe('derivePlanTiers (plans grid)', () => {
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' })
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', () => {

View file

@ -193,7 +193,7 @@ export function deriveBillingView(
if (!billing.logged_in || subscription?.logged_in === false) {
return {
notice: {
action: { label: 'Open portal', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
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.',
title: 'Connect your Nous account'
},
@ -296,7 +296,7 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return {
action: portalUrl ? { label: 'Open portal', url: portalUrl } : undefined,
action: portalUrl ? { label: 'Open portal', url: portalUrl } : undefined,
message: resolved.message,
title: resolved.title,
tone: 'warn'
@ -312,7 +312,7 @@ function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefi
}
return {
action: { label: 'Add card', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
action: { label: 'Add card', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.',
title: 'No payment method on file',
tone: 'warn'
@ -411,7 +411,7 @@ function derivePlanCard(
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
label: 'Adjust plan',
label: 'Adjust plan',
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
pending,
@ -448,7 +448,7 @@ function pendingTransition(
/**
* 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
* current tier: current = inert marker; higher = "Choose" opening the portal with
* the tier pre-selected; lower = an in-app "Downgrade" (chargeless, scheduled via the
* gateway). The already-scheduled downgrade target renders as an inert "Scheduled"
* marker; other lower tiers stay actionable (picking one reschedules). With no active
@ -523,7 +523,7 @@ function derivePlanTiers(
return {
...base,
action: { label: 'Choose', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
action: { label: 'Choose', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
state: 'upgrade'
}
})
@ -555,19 +555,9 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
}
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
// The no-card blocker is already spelled out by the page-level warn banner
// (noCardNotice); repeating it here — emoji and all — just clutters the row,
// so keep the plain "what buying does" line and let the controls sit disabled.
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now'
}
}
// Policy blockers (role / CLI billing off / remote spending off) outrank the
// missing card: adding a card would not unlock buying, so those name their own
// reason and never show a card call-to-action.
const disabledReason = buyCreditsDisabledReason(billing)
if (disabledReason) {
@ -578,6 +568,19 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
}
}
if (!billing.card) {
// A cluster of dead preset/amount/Buy controls can't explain itself — swap it
// for the reason and the one action that fixes it (the page-level warn banner
// still names the blocker up top).
return {
action: { label: 'Add card', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL },
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits now',
value: 'No card on file'
}
}
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
@ -594,6 +597,23 @@ const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below you
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const autoReload = billing.auto_reload
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
// Auto-refill charges the saved card, so while none is on file "off" isn't a
// choice the user can change — name the blocker on the row and offer the fix
// instead of a dead Manage button or a link-less portal caption. An enabled
// config (card removed later) falls through to its normal row so it can still
// be managed / turned off.
if (!billing.card && !autoReload?.enabled) {
return {
action: { label: 'Add card', url: portalUrl },
caption: 'Needs a card on file before it can be turned on.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: autoReload ? 'Off' : EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Refill when low'
}
}
if (!autoReload) {
return {
@ -608,7 +628,8 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
action: { label: 'Turn on', url: portalUrl },
caption: 'Turn on auto-refill from the portal.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
@ -621,10 +642,9 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
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
return {
action: { label: 'Reconcile', url: portalUrl },
action: { label: 'Reconcile', url: portalUrl },
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',