diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 6ba5f99a4f9..91171e018ba 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -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 () => { 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 a132e057db9..a2259d1c333 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 @@ -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', () => { 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 7611a055508..2581c0fdaea 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -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',