-
Notifications
You must be signed in to change notification settings - Fork 3.9k
fix(billing): wire billing interval through to purchasePlan on upgrade #5962
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b62e623
d49c8c5
058b525
093a93b
11e2052
838c3aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,51 +17,182 @@ 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('<BillingPanel />', () => { | ||
| 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(<Panel />); | ||
|
|
||
| // 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(<BillingPanel />); | ||
|
|
||
| // 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(<Panel />); | ||
| 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(<BillingPanel />); | ||
|
|
||
| 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(<BillingPanel />); | ||
|
|
||
| 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'); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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(<BillingPanel />); | ||
| 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(<BillingPanel />); | ||
| 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(<BillingPanel />); | ||
|
|
||
| fireEvent.click(screen.getByRole('button', { name: 'Open billing dashboard' })); | ||
| await waitFor(() => expect(openUrlMock).toHaveBeenCalledTimes(1)); | ||
| expect(openUrlMock).toHaveBeenLastCalledWith('https://tinyhumans.ai/dashboard'); | ||
| }); | ||
|
|
||
| it('invokes the navigation back handler from both the header and the inline button', async () => { | ||
| const Panel = await importPanel(); | ||
| render(<Panel />); | ||
| render(<BillingPanel />); | ||
|
|
||
| // The SettingsHeader back button (aria-label "Back") and the inline | ||
| // "Back to settings" button both route through navigateBack. | ||
| fireEvent.click(screen.getByRole('button', { name: 'Back' })); | ||
| 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(<BillingPanel />); | ||
|
|
||
| await waitFor(() => expect(screen.getByText('Network error')).toBeInTheDocument()); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert against a translation key or role, not raw error strings The test at line 124 asserts that the rendered output contains the exact string [RULE] unreliable-text-matching ·
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The strings asserted (
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disagreeing on this one, with the code — leaving it open for a human. The finding rests on a premise that does not hold here:
It does not. The panel stores the raw message and passes it straight through: // BillingPanel.tsx:35 and :61
.catch(err => setError(err instanceof Error ? err.message : String(err)))
...
<SettingsStatusLine saving={false} error={error} savingLabel="" />and // ui/StatusLine.tsx:21-22
if (error) {
content = <span className="text-coral-600 dark:text-coral-300">{error}</span>;
}So The suggested alternative is also not available: that container has If the panel later starts translating these messages, the right response is to assert the translation key at that point, not to loosen the assertion pre-emptively now. Leaving this open rather than resolving it, since I argued against the suggestion rather than acting on it. |
||
| }); | ||
|
|
||
| it('shows an error message when purchasePlan rejects', async () => { | ||
| purchasePlanMock.mockRejectedValue(new Error('Payment failed')); | ||
|
|
||
| render(<BillingPanel />); | ||
| 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(<BillingPanel />); | ||
| 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(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PlanTier>('FREE'); | ||
|
M3gA-Mind marked this conversation as resolved.
|
||
| const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly'); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| const [paymentMethod, setPaymentMethod] = useState<'card' | 'crypto'>('card'); | ||
| const [isPurchasing, setIsPurchasing] = useState(false); | ||
| const [purchasingTier, setPurchasingTier] = useState<PlanTier | null>(null); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [planLoading, setPlanLoading] = useState(true); | ||
| const [planKnown, setPlanKnown] = useState(false); | ||
| const paymentConfirmed = false; | ||
|
|
||
| useEffect(() => { | ||
| billingApi | ||
|
M3gA-Mind marked this conversation as resolved.
|
||
| .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<void> => { | ||
| setError(null); | ||
| setIsPurchasing(true); | ||
| setPurchasingTier(tier); | ||
| try { | ||
| if (paymentMethod === 'crypto') { | ||
|
M3gA-Mind marked this conversation as resolved.
|
||
| const charge = await billingApi.createCoinbaseCharge(tier); | ||
|
M3gA-Mind marked this conversation as resolved.
|
||
| await openUrl(charge.hostedUrl); | ||
| } else { | ||
| const session = await billingApi.purchasePlan(buildPlanId(tier, billingInterval)); | ||
|
M3gA-Mind marked this conversation as resolved.
|
||
| 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. | ||
| <SettingsPanel description={t('settings.billing.movedToWebDesc')}> | ||
| <p className="text-xs font-semibold uppercase tracking-wide text-content-muted"> | ||
| {t('settings.billing.movedToWeb')} | ||
| </p> | ||
| <SettingsPanel> | ||
| <SettingsStatusLine saving={false} error={error} savingLabel="" /> | ||
|
M3gA-Mind marked this conversation as resolved.
|
||
| <SubscriptionPlans | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Mounting AGENTS.md reference: AGENTS.md:L1258-L1260 Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid flag — the gitbooks doc at lines 51-53 does say the panel "intentionally has no embedded payment UI." The original base code at Issue #5865 describes a user selecting annual billing in the in-app plan selection UI, which implies the in-app flow existed at the time the bug was filed (and SubscriptionPlans.tsx has been in the codebase since If the product direction is still web-only, this PR needs to be scoped down to a simpler fix (e.g. persisting the interval preference so it survives the redirect, or handling it in the web dashboard). Flagging for maintainer clarification — I will not resolve this thread until the product decision is confirmed.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not resolving this — it needs a maintainer's product decision, not a reviewer's. Adding evidence rather than an opinion. I checked the claim independently and it holds, in both halves: 1. The documentation says it explicitly.
2. It is live, not aspirational. On One correction to the PR's framing that I think changes the conclusion, and is the reason I am not treating this as a nitpick: the body describes the interval toggle as "orphaned" local state, implying a regression to restore. But on Either way the decision is above a reviewer's pay grade, and it is binary:
@YellowSnnowmann has been holding this thread pending exactly that call, which I think was the right instinct. I have not approved this PR, and this thread is why — the rest of it is in good shape (the interval and crypto coverage gap is now genuinely closed; I verified it, see my summary comment). |
||
| currentTier={currentTier} | ||
| billingInterval={billingInterval} | ||
| setBillingInterval={setBillingInterval} | ||
| paymentMethod={paymentMethod} | ||
| setPaymentMethod={handleSetPaymentMethod} | ||
| isPurchasing={isPurchasing} | ||
| purchasingTier={purchasingTier} | ||
| paymentConfirmed={paymentConfirmed} | ||
| upgradesDisabled={planLoading || !planKnown} | ||
| onUpgrade={handleUpgrade} | ||
| /> | ||
|
|
||
| <div className="flex flex-wrap gap-3"> | ||
| <Button | ||
| type="button" | ||
| variant="primary" | ||
| variant="secondary" | ||
| size="md" | ||
| onClick={() => { | ||
| void openUrl(BILLING_DASHBOARD_URL); | ||
| }}> | ||
| onClick={() => void openUrl(BILLING_DASHBOARD_URL)}> | ||
| {t('settings.billing.openDashboard')} | ||
| </Button> | ||
| <Button type="button" variant="secondary" size="md" onClick={navigateBack}> | ||
| <Button type="button" variant="tertiary" size="md" onClick={navigateBack}> | ||
| {t('settings.billing.backToSettings')} | ||
| </Button> | ||
| </div> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.