mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
305a3c7424
commit
26571fa940
3 changed files with 137 additions and 48 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue