diff --git a/app/src/components/settings/panels/BillingPanel.test.tsx b/app/src/components/settings/panels/BillingPanel.test.tsx index bd7dc1c6c1..9f8bfbc265 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,134 @@ 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)); + expect(purchasePlanMock).toHaveBeenCalledWith('BASIC_MONTHLY'); + await waitFor(() => + expect(openUrlMock).toHaveBeenCalledWith('https://checkout.stripe.com/test') + ); + }); + + // 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(); fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' })); await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); @@ -53,8 +152,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. @@ -62,4 +160,39 @@ 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()); + }); + + 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 8e85801cfc..25c18c253b 100644 --- a/app/src/components/settings/panels/BillingPanel.tsx +++ b/app/src/components/settings/panels/BillingPanel.tsx @@ -1,34 +1,95 @@ +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 { SettingsStatusLine } from '../controls'; 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 [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); + setPlanKnown(true); + }) + .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); + 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); + } else { + throw new Error('Checkout session did not return a redirect URL'); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } 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')} -

+ + +
-
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')}