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>
179 lines
4.8 KiB
TypeScript
179 lines
4.8 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 { SelectPlanPage } from '../SelectPlanPage'
|
|
import { useBillingStore } from '@/store/billingStore'
|
|
|
|
vi.mock('@/api/billing', () => ({
|
|
billingApi: {
|
|
createCheckoutSession: vi.fn(),
|
|
},
|
|
}))
|
|
vi.mock('@/api/plans', () => ({
|
|
plansApi: {
|
|
getPublic: vi.fn(),
|
|
},
|
|
}))
|
|
vi.mock('@/lib/toast', () => ({
|
|
toast: {
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
import { billingApi } from '@/api/billing'
|
|
import { plansApi } from '@/api/plans'
|
|
|
|
const createCheckoutSession = billingApi.createCheckoutSession as unknown as ReturnType<typeof vi.fn>
|
|
const getPublic = plansApi.getPublic as unknown as ReturnType<typeof vi.fn>
|
|
|
|
const PLAN_FIXTURE = [
|
|
{
|
|
plan: 'starter',
|
|
display_name: 'Starter',
|
|
description: 'For solo techs.',
|
|
monthly_price_cents: 1900,
|
|
annual_price_cents: 19000,
|
|
max_seats: 1,
|
|
sort_order: 1,
|
|
is_public: true,
|
|
},
|
|
{
|
|
plan: 'pro',
|
|
display_name: 'Pro',
|
|
description: 'For growing teams.',
|
|
monthly_price_cents: 4900,
|
|
annual_price_cents: 49000,
|
|
max_seats: 5,
|
|
sort_order: 2,
|
|
is_public: true,
|
|
},
|
|
{
|
|
plan: 'enterprise',
|
|
display_name: 'Enterprise',
|
|
description: 'Custom.',
|
|
monthly_price_cents: null,
|
|
annual_price_cents: null,
|
|
max_seats: null,
|
|
sort_order: 3,
|
|
is_public: true,
|
|
},
|
|
]
|
|
|
|
function renderPage() {
|
|
return render(
|
|
<MemoryRouter>
|
|
<SelectPlanPage />
|
|
</MemoryRouter>,
|
|
)
|
|
}
|
|
|
|
describe('SelectPlanPage', () => {
|
|
// Stub window.location.href setter so we can assert without a real navigation.
|
|
let assignedHref: string | null = null
|
|
const originalLocation = window.location
|
|
|
|
beforeEach(() => {
|
|
getPublic.mockReset()
|
|
createCheckoutSession.mockReset()
|
|
assignedHref = null
|
|
Object.defineProperty(window, 'location', {
|
|
configurable: true,
|
|
value: {
|
|
...originalLocation,
|
|
get href() {
|
|
return assignedHref ?? originalLocation.href
|
|
},
|
|
set href(v: string) {
|
|
assignedHref = v
|
|
},
|
|
},
|
|
})
|
|
useBillingStore.setState({
|
|
subscription: null,
|
|
planBilling: null,
|
|
planLimits: {},
|
|
enabledFeatures: {},
|
|
isLoading: false,
|
|
error: null,
|
|
})
|
|
})
|
|
|
|
it('renders plan cards from plansApi', async () => {
|
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
|
renderPage()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
|
|
})
|
|
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
|
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
|
|
})
|
|
|
|
it('Continue to checkout calls createCheckoutSession and redirects', async () => {
|
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
|
createCheckoutSession.mockResolvedValueOnce({ url: 'https://checkout.stripe.com/abc' })
|
|
|
|
renderPage()
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
|
|
})
|
|
|
|
// Bump seats and switch to annual.
|
|
fireEvent.change(screen.getByTestId('seats-input'), { target: { value: '3' } })
|
|
fireEvent.click(screen.getByTestId('interval-annual'))
|
|
|
|
fireEvent.click(screen.getByTestId('plan-cta-pro'))
|
|
|
|
await waitFor(() => {
|
|
expect(createCheckoutSession).toHaveBeenCalledWith({
|
|
plan: 'pro',
|
|
seats: 3,
|
|
billing_interval: 'annual',
|
|
})
|
|
})
|
|
await waitFor(() => {
|
|
expect(assignedHref).toBe('https://checkout.stripe.com/abc')
|
|
})
|
|
})
|
|
|
|
it('Talk to sales links to /contact-sales for enterprise', async () => {
|
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
|
renderPage()
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('plan-cta-enterprise')).toBeInTheDocument()
|
|
})
|
|
const cta = screen.getByTestId('plan-cta-enterprise') as HTMLAnchorElement
|
|
expect(cta.getAttribute('href')).toBe('/contact-sales')
|
|
})
|
|
|
|
it('marks the active current plan as Current plan and disables its CTA', async () => {
|
|
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
|
|
useBillingStore.setState({
|
|
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,
|
|
},
|
|
planBilling: null,
|
|
planLimits: {},
|
|
enabledFeatures: {},
|
|
isLoading: false,
|
|
error: null,
|
|
})
|
|
|
|
renderPage()
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('plan-current-pro')).toBeInTheDocument()
|
|
})
|
|
const cta = screen.getByTestId('plan-cta-pro') as HTMLButtonElement
|
|
expect(cta).toBeDisabled()
|
|
})
|
|
})
|