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>
207 lines
5.6 KiB
TypeScript
207 lines
5.6 KiB
TypeScript
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.',
|
|
)
|
|
})
|
|
})
|
|
})
|