From b62e62391abb3f8bc18ce13c8d61c70d98835a1b Mon Sep 17 00:00:00 2001 From: shanu Date: Wed, 2 Sep 2026 16:33:16 +0530 Subject: [PATCH 1/5] fix(billing): wire billing interval through to purchasePlan on upgrade BillingPanel was detached from SubscriptionPlans after the billing redirect refactor, leaving billingInterval as dead local state that never reached purchasePlan(). Re-integrate SubscriptionPlans with full API wiring: getCurrentPlan() on mount sets currentTier, billingInterval controls buildPlanId(tier, interval) passed to purchasePlan(), and Coinbase charges route through createCoinbaseCharge(). Closes #5865 --- .../settings/panels/BillingPanel.test.tsx | 97 ++++++++++++++----- .../settings/panels/BillingPanel.tsx | 67 ++++++++++--- 2 files changed, 130 insertions(+), 34 deletions(-) diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index bd7dc1c6c1..12d5c193d2 100644 --- a/app/src/components/settings/panels/BillingPanel.test.tsx +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import BillingPanel from './BillingPanel'; + const navigateBack = vi.fn(); vi.mock('../hooks/useSettingsNavigation', () => ({ @@ -15,37 +17,89 @@ vi.mock('../hooks/useSettingsNavigation', () => ({ const openUrlMock = vi.fn(); vi.mock('../../../utils/openUrl', () => ({ openUrl: (url: string) => openUrlMock(url) })); -async function importPanel() { - vi.resetModules(); - const mod = await import('./BillingPanel'); - return mod.default; -} +const getCurrentPlanMock = vi.fn(); +const purchasePlanMock = vi.fn(); +const createCoinbaseChargeMock = vi.fn(); + +vi.mock('../../../services/api/billingApi', () => ({ + billingApi: { + getCurrentPlan: (...args: unknown[]) => getCurrentPlanMock(...args), + purchasePlan: (...args: unknown[]) => purchasePlanMock(...args), + createCoinbaseCharge: (...args: unknown[]) => createCoinbaseChargeMock(...args), + }, +})); describe('', () => { beforeEach(() => { vi.clearAllMocks(); openUrlMock.mockResolvedValue(undefined); + getCurrentPlanMock.mockResolvedValue({ + plan: 'FREE', + hasActiveSubscription: false, + planExpiry: null, + subscription: null, + monthlyBudgetUsd: 0, + weeklyBudgetUsd: 0, + }); + purchasePlanMock.mockResolvedValue({ + checkoutUrl: 'https://checkout.stripe.com/test', + sessionId: 'test-session', + }); + createCoinbaseChargeMock.mockResolvedValue({ + gatewayTransactionId: 'test-gw', + hostedUrl: 'https://commerce.coinbase.com/test', + status: 'NEW', + expiresAt: '2026-01-01T00:00:00Z', + }); }); - it('renders the "billing moved to web" view without auto-opening the browser', async () => { - const Panel = await importPanel(); - render(); - - // Billing no longer auto-opens the dashboard on mount (auto-open removed): - // the panel just explains billing moved to the web. - expect( - screen.getByText( - /Subscription changes, payment methods, credits, and invoices are now managed/ - ) - ).toBeInTheDocument(); + it('renders the plan selector and the dashboard button without auto-opening the browser', async () => { + render(); + + // SubscriptionPlans renders its own title; billing frequency selection is + // back in-app so users can change their plan without leaving the desktop app. + expect(screen.getByText('Choose a Plan')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Open billing dashboard' })).toBeInTheDocument(); - // No openUrl call happens on mount. + + // getCurrentPlan is called on mount but must not trigger a browser open. + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); expect(openUrlMock).not.toHaveBeenCalled(); }); - it('opens the dashboard when the user clicks the primary button', async () => { - const Panel = await importPanel(); - render(); + it('loads the current plan tier on mount and passes it to SubscriptionPlans', async () => { + getCurrentPlanMock.mockResolvedValue({ + plan: 'BASIC', + hasActiveSubscription: true, + planExpiry: null, + subscription: null, + monthlyBudgetUsd: 20, + weeklyBudgetUsd: 10, + }); + + render(); + + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); + // With BASIC as current tier the BASIC card shows the "Current plan" badge. + expect(await screen.findByText('Current plan')).toBeInTheDocument(); + }); + + it('upgrade with card payment calls purchasePlan and opens the checkout URL', async () => { + render(); + + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); + + // Both BASIC and PRO show upgrade buttons when current tier is FREE. + const upgradeButtons = screen.getAllByRole('button', { name: 'Upgrade' }); + fireEvent.click(upgradeButtons[0]); + + await waitFor(() => expect(purchasePlanMock).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(openUrlMock).toHaveBeenCalledWith('https://checkout.stripe.com/test') + ); + }); + + it('opens the billing dashboard when the user clicks the secondary button', async () => { + render(); fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' })); await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); @@ -53,8 +107,7 @@ describe('', () => { }); it('invokes the navigation back handler from both the header and the inline button', async () => { - const Panel = await importPanel(); - render(); + render(); // The SettingsHeader back button (aria-label "Back") and the inline // "Back to settings" button both route through navigateBack. diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 8e85801cfc..58a1d11ba9 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -1,34 +1,77 @@ +import { useEffect, useState } from 'react'; + import { useT } from '../../../lib/i18n/I18nContext'; +import { billingApi } from '../../../services/api/billingApi'; +import type { PlanTier } from '../../../types/api'; import { BILLING_DASHBOARD_URL } from '../../../utils/links'; import { openUrl } from '../../../utils/openUrl'; import Button from '../../ui/Button'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import SettingsPanel from '../layout/SettingsPanel'; +import SubscriptionPlans from './billing/SubscriptionPlans'; +import { buildPlanId } from './billingHelpers'; const BillingPanel = () => { const { t } = useT(); const { navigateBack } = useSettingsNavigation(); + const [currentTier, setCurrentTier] = useState('FREE'); + const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); + const [paymentMethod, setPaymentMethod] = useState<'card' | 'crypto'>('card'); + const [isPurchasing, setIsPurchasing] = useState(false); + const [purchasingTier, setPurchasingTier] = useState(null); + const paymentConfirmed = false; + + useEffect(() => { + billingApi + .getCurrentPlan() + .then(data => setCurrentTier(data.plan)) + .catch(() => {}); + }, []); + + const handleUpgrade = async (tier: PlanTier): Promise => { + setIsPurchasing(true); + setPurchasingTier(tier); + try { + if (paymentMethod === 'crypto') { + const charge = await billingApi.createCoinbaseCharge(tier); + await openUrl(charge.hostedUrl); + } else { + const session = await billingApi.purchasePlan(buildPlanId(tier, billingInterval)); + if (session.checkoutUrl) { + await openUrl(session.checkoutUrl); + } + } + } catch { + // errors surface through the standard error boundary + } finally { + setIsPurchasing(false); + setPurchasingTier(null); + } + }; return ( - // The description rides the scaffold's own slot: SettingsPanel already - // renders the page h1 (from the settings route registry), so a second - // `text-2xl` heading here stacked two page titles on one page. - -

- {t('settings.billing.movedToWeb')} -

+ +
-
From d49c8c5d0005daaabe52099f332effa4bd3bb77d Mon Sep 17 00:00:00 2001 From: shanu Date: Wed, 2 Sep 2026 17:14:33 +0530 Subject: [PATCH 2/5] surface getCurrentPlan and upgrade errors in BillingPanel Silent catch blocks left the panel in a broken state with no user feedback when plan loading or checkout initiation failed. Add shared error state, surface it via SettingsStatusLine, and cover both paths with regression tests. --- .../settings/panels/BillingPanel.test.tsx | 20 +++++++++++++++++++ .../settings/panels/BillingPanel.tsx | 10 +++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index 12d5c193d2..fb227360d4 100644 --- a/app/src/components/settings/panels/BillingPanel.test.tsx +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -115,4 +115,24 @@ describe('', () => { fireEvent.click(screen.getByRole('button', { name: 'Back to settings' })); expect(navigateBack).toHaveBeenCalledTimes(2); }); + + it('shows an error message when getCurrentPlan rejects', async () => { + getCurrentPlanMock.mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument()); + }); + + it('shows an error message when purchasePlan rejects', async () => { + purchasePlanMock.mockRejectedValue(new Error('Payment failed')); + + render(); + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); + + const upgradeButtons = screen.getAllByRole('button', { name: 'Upgrade' }); + fireEvent.click(upgradeButtons[0]); + + await waitFor(() => expect(screen.getByText('Payment failed')).toBeInTheDocument()); + }); }); diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 58a1d11ba9..7d2ab16657 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -6,6 +6,7 @@ import type { PlanTier } from '../../../types/api'; import { BILLING_DASHBOARD_URL } from '../../../utils/links'; import { openUrl } from '../../../utils/openUrl'; import Button from '../../ui/Button'; +import { SettingsStatusLine } from '../controls'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import SettingsPanel from '../layout/SettingsPanel'; import SubscriptionPlans from './billing/SubscriptionPlans'; @@ -19,16 +20,18 @@ const BillingPanel = () => { const [paymentMethod, setPaymentMethod] = useState<'card' | 'crypto'>('card'); const [isPurchasing, setIsPurchasing] = useState(false); const [purchasingTier, setPurchasingTier] = useState(null); + const [error, setError] = useState(null); const paymentConfirmed = false; useEffect(() => { billingApi .getCurrentPlan() .then(data => setCurrentTier(data.plan)) - .catch(() => {}); + .catch(err => setError(err instanceof Error ? err.message : String(err))); }, []); const handleUpgrade = async (tier: PlanTier): Promise => { + setError(null); setIsPurchasing(true); setPurchasingTier(tier); try { @@ -41,8 +44,8 @@ const BillingPanel = () => { await openUrl(session.checkoutUrl); } } - } catch { - // errors surface through the standard error boundary + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); } finally { setIsPurchasing(false); setPurchasingTier(null); @@ -51,6 +54,7 @@ const BillingPanel = () => { return ( + Date: Wed, 2 Sep 2026 17:20:59 +0530 Subject: [PATCH 3/5] fix crypto payment UX and disable upgrades during plan load When crypto is selected, Coinbase charges annually regardless of the displayed interval. Auto-switch billingInterval to 'annual' on crypto selection so the price shown matches the charge created. Block upgrade buttons (upgradesDisabled) while getCurrentPlan is in flight so an existing paid subscriber is not offered a re-upgrade at the 'FREE' default before the real tier loads. --- app/src/components/settings/panels/BillingPanel.tsx | 12 ++++++++++-- .../settings/panels/billing/SubscriptionPlans.tsx | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 7d2ab16657..711647df82 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -21,15 +21,22 @@ const BillingPanel = () => { const [isPurchasing, setIsPurchasing] = useState(false); const [purchasingTier, setPurchasingTier] = useState(null); const [error, setError] = useState(null); + const [planLoading, setPlanLoading] = useState(true); const paymentConfirmed = false; useEffect(() => { billingApi .getCurrentPlan() .then(data => setCurrentTier(data.plan)) - .catch(err => setError(err instanceof Error ? err.message : String(err))); + .catch(err => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setPlanLoading(false)); }, []); + const handleSetPaymentMethod = (method: 'card' | 'crypto') => { + setPaymentMethod(method); + if (method === 'crypto') setBillingInterval('annual'); + }; + const handleUpgrade = async (tier: PlanTier): Promise => { setError(null); setIsPurchasing(true); @@ -60,10 +67,11 @@ const BillingPanel = () => { billingInterval={billingInterval} setBillingInterval={setBillingInterval} paymentMethod={paymentMethod} - setPaymentMethod={setPaymentMethod} + setPaymentMethod={handleSetPaymentMethod} isPurchasing={isPurchasing} purchasingTier={purchasingTier} paymentConfirmed={paymentConfirmed} + upgradesDisabled={planLoading} onUpgrade={handleUpgrade} /> diff --git a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx index 2838c811e9..bcf273b82a 100644 --- a/app/src/components/settings/panels/billing/SubscriptionPlans.tsx +++ b/app/src/components/settings/panels/billing/SubscriptionPlans.tsx @@ -17,6 +17,7 @@ interface SubscriptionPlansProps { isPurchasing: boolean; purchasingTier: PlanTier | null; paymentConfirmed: boolean; + upgradesDisabled?: boolean; onUpgrade: (tier: PlanTier) => void; } @@ -29,6 +30,7 @@ const SubscriptionPlans = ({ isPurchasing, purchasingTier, paymentConfirmed, + upgradesDisabled = false, onUpgrade, }: SubscriptionPlansProps) => { const { t } = useT(); @@ -228,7 +230,7 @@ const SubscriptionPlans = ({ size="sm" className="rounded-full" onClick={() => onUpgrade(plan.tier)} - disabled={isPurchasing}> + disabled={isPurchasing || upgradesDisabled}> {isThisPurchasing ? t('settings.billing.subscription.waiting') : t('settings.billing.subscription.upgrade')} From 093a93bb4e5370d3f1ceba8163d0f23dfd13b053 Mon Sep 17 00:00:00 2001 From: shanu Date: Wed, 2 Sep 2026 20:56:38 +0530 Subject: [PATCH 4/5] fix: surface error when checkoutUrl is null; keep upgrades disabled on plan load failure - Throw when purchasePlan returns no checkoutUrl so the catch block surfaces it via SettingsStatusLine instead of silently no-oping - Add planKnown state so upgradesDisabled stays true when getCurrentPlan rejects (avoids showing FREE tier with enabled upgrade buttons) - Assert plan ID argument in upgrade test (BASIC_MONTHLY) - Add test for null checkoutUrl case --- .../settings/panels/BillingPanel.test.tsx | 16 ++++++++++++++++ .../components/settings/panels/BillingPanel.tsx | 10 ++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index fb227360d4..eef3f411cc 100644 --- a/app/src/components/settings/panels/BillingPanel.test.tsx +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -93,6 +93,7 @@ describe('', () => { fireEvent.click(upgradeButtons[0]); await waitFor(() => expect(purchasePlanMock).toHaveBeenCalledTimes(1)); + expect(purchasePlanMock).toHaveBeenCalledWith('BASIC_MONTHLY'); await waitFor(() => expect(openUrlMock).toHaveBeenCalledWith('https://checkout.stripe.com/test') ); @@ -135,4 +136,19 @@ describe('', () => { await waitFor(() => expect(screen.getByText('Payment failed')).toBeInTheDocument()); }); + + it('shows an error when purchasePlan returns no checkout URL', async () => { + purchasePlanMock.mockResolvedValue({ checkoutUrl: null, sessionId: 'test-session' }); + + render(); + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); + + const upgradeButtons = screen.getAllByRole('button', { name: 'Upgrade' }); + fireEvent.click(upgradeButtons[0]); + + await waitFor(() => + expect(screen.getByText('Checkout session did not return a redirect URL')).toBeInTheDocument() + ); + expect(openUrlMock).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/components/settings/panels/BillingPanel.tsx b/app/src/components/settings/panels/BillingPanel.tsx index 711647df82..25c18c253b 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -22,12 +22,16 @@ const BillingPanel = () => { const [purchasingTier, setPurchasingTier] = useState(null); const [error, setError] = useState(null); const [planLoading, setPlanLoading] = useState(true); + const [planKnown, setPlanKnown] = useState(false); const paymentConfirmed = false; useEffect(() => { billingApi .getCurrentPlan() - .then(data => setCurrentTier(data.plan)) + .then(data => { + setCurrentTier(data.plan); + setPlanKnown(true); + }) .catch(err => setError(err instanceof Error ? err.message : String(err))) .finally(() => setPlanLoading(false)); }, []); @@ -49,6 +53,8 @@ const BillingPanel = () => { const session = await billingApi.purchasePlan(buildPlanId(tier, billingInterval)); if (session.checkoutUrl) { await openUrl(session.checkoutUrl); + } else { + throw new Error('Checkout session did not return a redirect URL'); } } } catch (err) { @@ -71,7 +77,7 @@ const BillingPanel = () => { isPurchasing={isPurchasing} purchasingTier={purchasingTier} paymentConfirmed={paymentConfirmed} - upgradesDisabled={planLoading} + upgradesDisabled={planLoading || !planKnown} onUpgrade={handleUpgrade} /> From 11e2052a2a6101b16db48258eeebff968436be03 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 3 Sep 2026 03:20:26 +0530 Subject: [PATCH 5/5] test(billing): pin the interval and crypto paths the panel exists to fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite asserted purchasePlan was called with 'BASIC_MONTHLY' — the DEFAULT interval — so it stayed green with buildPlanId(tier, billingInterval) hardcoded back to 'monthly', i.e. with the #5865 bug fully restored. Across the whole diff "annual" and "yearly" appeared only in BillingPanel.tsx, never in a test. createCoinbaseChargeMock was declared and stubbed and never asserted on, so the crypto branch of handleUpgrade was unexecuted. Two cases: - selecting Annual then Upgrade sends 'BASIC_YEARLY' - selecting crypto sends a Coinbase charge, opens its hostedUrl, does not touch the Stripe path, and leaves the Monthly button disabled — which also pins the interval coupling from the Codex P1, so the price on screen cannot disagree with the charge that was created Revert-checked separately: hardcoding buildPlanId(tier, 'monthly') fails the annual case on BASIC_YEARLY with the other nine still passing; disabling the crypto branch fails the crypto case on createCoinbaseCharge never being called. No production code changed. --- .../settings/panels/BillingPanel.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index eef3f411cc..9f8bfbc265 100644 --- a/app/src/components/settings/panels/BillingPanel.test.tsx +++ b/app/src/components/settings/panels/BillingPanel.test.tsx @@ -99,6 +99,50 @@ describe('', () => { ); }); + // The reason this PR exists: the interval toggle must reach `purchasePlan`. + // The monthly case above passes on the DEFAULT interval, so it stays green + // even if `buildPlanId(tier, billingInterval)` is hardcoded back to + // 'monthly' — i.e. even with the bug in #5865 fully restored. This is the + // case that fails when that happens. + it('upgrade after selecting Annual sends the yearly plan id', async () => { + render(); + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: 'Annual' })); + + const upgradeButtons = await screen.findAllByRole('button', { name: 'Upgrade' }); + fireEvent.click(upgradeButtons[0]); + + await waitFor(() => expect(purchasePlanMock).toHaveBeenCalledTimes(1)); + expect(purchasePlanMock).toHaveBeenCalledWith('BASIC_YEARLY'); + }); + + // The crypto branch of `handleUpgrade` had no test at all: the mock was + // declared and stubbed but never asserted on, so the whole branch was + // unexecuted. Also pins the interval coupling from the Codex P1 — selecting + // crypto forces `annual`, so the price on screen matches the charge. + it('upgrade with crypto creates a Coinbase charge and opens the hosted URL', async () => { + render(); + await waitFor(() => expect(getCurrentPlanMock).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('switch')); + + const upgradeButtons = await screen.findAllByRole('button', { name: 'Upgrade' }); + fireEvent.click(upgradeButtons[0]); + + await waitFor(() => expect(createCoinbaseChargeMock).toHaveBeenCalledTimes(1)); + expect(createCoinbaseChargeMock).toHaveBeenCalledWith('BASIC'); + // Crypto must never go through the Stripe path. + expect(purchasePlanMock).not.toHaveBeenCalled(); + await waitFor(() => + expect(openUrlMock).toHaveBeenCalledWith('https://commerce.coinbase.com/test') + ); + // Selecting crypto switches the interval to annual, so the monthly + // button is disabled and the displayed price cannot disagree with the + // charge that was created. + expect(screen.getByRole('button', { name: 'Monthly' })).toBeDisabled(); + }); + it('opens the billing dashboard when the user clicks the secondary button', async () => { render();