feat(billing): add /account/billing and /account/billing/select-plan pages
Wires up the missing frontend billing surfaces that TrialPill, UpgradePrompt, NextStepCard, and SetupChecklist all link to. Trial-expired, canceled, past-due, and "Pick a plan" CTAs no longer 404. - BillingPage: subscription summary, status-specific messaging (trialing / past_due / canceled / complimentary), Manage billing button routed through the Stripe Customer Portal, and a Pick/Change-plan link. - SelectPlanPage: plan picker with monthly/annual toggle + seat count. Starter/Pro hit /billing/checkout-session; Enterprise links to /contact-sales. Active current plan is tagged "Current plan" with a disabled CTA. - billingApi.getPortalSession + createCheckoutSession; getPortalSession surfaces a typed BillingPortalError (no_stripe_customer / stripe_not_ configured) so the UI can show the right toast. - AccountSettingsPage gets a Billing link card so the page is discoverable from the account hub. - 10 new vitest cases covering subscription summary, trial/past-due/ canceled/complimentary states, portal-session error fallback, plan-card rendering, checkout payload, and current-plan badge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
206
frontend/src/pages/account/__tests__/BillingPage.test.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
import { BillingPage } from '../BillingPage'
|
||||
import { useBillingStore } from '@/store/billingStore'
|
||||
import { BillingPortalError } from '@/types/billing'
|
||||
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
|
||||
|
||||
vi.mock('@/api/billing', () => ({
|
||||
billingApi: {
|
||||
getPortalSession: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { billingApi } from '@/api/billing'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
const getPortalSession = billingApi.getPortalSession as unknown as ReturnType<typeof vi.fn>
|
||||
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
function setBilling(opts: {
|
||||
subscription: SubscriptionState | null
|
||||
planBilling?: PlanBillingState | null
|
||||
}) {
|
||||
useBillingStore.setState({
|
||||
subscription: opts.subscription,
|
||||
planBilling:
|
||||
opts.planBilling ??
|
||||
({
|
||||
display_name: 'Pro',
|
||||
description: 'Pro plan',
|
||||
monthly_price_cents: 4900,
|
||||
annual_price_cents: 49000,
|
||||
} as PlanBillingState),
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<BillingPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('BillingPage', () => {
|
||||
beforeEach(() => {
|
||||
getPortalSession.mockReset()
|
||||
toastError.mockReset()
|
||||
useBillingStore.setState({
|
||||
subscription: null,
|
||||
planBilling: null,
|
||||
planLimits: {},
|
||||
enabledFeatures: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders subscription summary from useBillingStore', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Billing' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
// Seats shown
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows trial-ends message + Pick a plan CTA when trialing', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'trialing',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-22T00:00:00Z',
|
||||
current_period_end: '2026-05-06T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: false,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('trial-message').textContent).toMatch(/Trial ends/)
|
||||
const pickPlan = screen.getByTestId('select-plan-link')
|
||||
expect(pickPlan.getAttribute('href')).toBe('/account/billing/select-plan')
|
||||
})
|
||||
|
||||
it('shows past-due banner with update payment CTA when status=past_due', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'past_due',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('past-due-banner')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('past-due-update-payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders complimentary message and hides CTAs when complimentary', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'complimentary',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: null,
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: null,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('complimentary-message')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('manage-billing-button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('select-plan-link')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('change-plan-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders canceled message + Pick a plan CTA when canceled', () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'canceled',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-03-01T00:00:00Z',
|
||||
current_period_end: '2026-04-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: false,
|
||||
is_paid: false,
|
||||
},
|
||||
})
|
||||
|
||||
renderPage()
|
||||
|
||||
expect(screen.getByTestId('canceled-message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-plan-link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows toast when portal session fails with no_stripe_customer', async () => {
|
||||
setBilling({
|
||||
subscription: {
|
||||
status: 'active',
|
||||
plan: 'pro',
|
||||
current_period_start: '2026-04-01T00:00:00Z',
|
||||
current_period_end: '2026-05-01T00:00:00Z',
|
||||
cancel_at_period_end: false,
|
||||
seat_limit: 5,
|
||||
has_pro_entitlement: true,
|
||||
is_paid: true,
|
||||
},
|
||||
})
|
||||
getPortalSession.mockRejectedValueOnce(
|
||||
new BillingPortalError('no_stripe_customer'),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
fireEvent.click(screen.getByTestId('manage-billing-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
'Complete checkout first to access billing portal.',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user