diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts index 2ba56173..4a4bb9ce 100644 --- a/frontend/src/api/billing.ts +++ b/frontend/src/api/billing.ts @@ -1,5 +1,15 @@ +import { AxiosError } from 'axios' + import apiClient from './client' -import type { BillingStateApiResponse, BillingStatePayload } from '@/types' +import { + BillingPortalError, + type BillingPortalErrorCode, + type BillingPortalSessionResponse, + type BillingStateApiResponse, + type BillingStatePayload, + type CheckoutSessionRequest, + type CheckoutSessionResponse, +} from '@/types/billing' /** * Single boundary where the snake_case backend payload is transformed @@ -22,6 +32,48 @@ export const billingApi = { const response = await apiClient.get('/billing/state') return transformBillingState(response.data) }, + + /** + * Request a Stripe Customer Portal session URL for the active account. + * + * Throws a typed `BillingPortalError` when: + * - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled) + * - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet + * + * Other errors (5xx, network) propagate as the underlying AxiosError. + */ + async getPortalSession(): Promise { + try { + const response = await apiClient.get( + '/billing/portal-session', + ) + return response.data + } catch (err) { + if (err instanceof AxiosError && err.response) { + const { status, data } = err.response + const code: BillingPortalErrorCode | null = + status === 503 + ? 'stripe_not_configured' + : status === 400 && data?.detail?.error === 'no_stripe_customer' + ? 'no_stripe_customer' + : null + if (code) { + throw new BillingPortalError(code) + } + } + throw err + } + }, + + async createCheckoutSession( + payload: CheckoutSessionRequest, + ): Promise { + const response = await apiClient.post( + '/billing/checkout-session', + payload, + ) + return response.data + }, } export default billingApi diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 439b5bd3..5f5ae8a9 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -6,6 +6,7 @@ import { Check, Clock, Copy, + CreditCard, Crown, FolderTree, Loader2, @@ -598,6 +599,12 @@ export function AccountSettingsPage() { title="Profile" description="Your name, email, and personal preferences" /> + } + title="Billing" + description="Subscription, payment method, and invoices" + /> {isAccountOwner && ( diff --git a/frontend/src/pages/account/BillingPage.tsx b/frontend/src/pages/account/BillingPage.tsx new file mode 100644 index 00000000..dd4c03d6 --- /dev/null +++ b/frontend/src/pages/account/BillingPage.tsx @@ -0,0 +1,267 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react' + +import { billingApi } from '@/api/billing' +import { Button } from '@/components/ui/Button' +import { PageMeta } from '@/components/common/PageMeta' +import { useBillingStore } from '@/store/billingStore' +import { BillingPortalError } from '@/types/billing' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' + +function formatDate(value: string | null | undefined): string { + if (!value) return '—' + return new Date(value).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +function statusLabel(status: string): string { + switch (status) { + case 'trialing': + return 'Trialing' + case 'active': + return 'Active' + case 'past_due': + return 'Past due' + case 'canceled': + return 'Canceled' + case 'incomplete': + return 'Incomplete' + case 'complimentary': + return 'Complimentary' + default: + return status + } +} + +function statusToneClass(status: string): string { + switch (status) { + case 'active': + case 'complimentary': + return 'text-success' + case 'trialing': + return 'text-info' + case 'past_due': + case 'incomplete': + return 'text-warning' + case 'canceled': + return 'text-danger' + default: + return 'text-muted-foreground' + } +} + +export function BillingPage() { + const subscription = useBillingStore((s) => s.subscription) + const planBilling = useBillingStore((s) => s.planBilling) + const isLoading = useBillingStore((s) => s.isLoading) + + const [openingPortal, setOpeningPortal] = useState(false) + + const status = subscription?.status ?? null + const isComplimentary = status === 'complimentary' + const isTrialing = status === 'trialing' + const isPastDue = status === 'past_due' + const isCanceled = status === 'canceled' + + const handleOpenPortal = async () => { + setOpeningPortal(true) + try { + const { url } = await billingApi.getPortalSession() + window.location.href = url + } catch (err) { + if (err instanceof BillingPortalError) { + if (err.code === 'no_stripe_customer') { + toast.error('Complete checkout first to access billing portal.') + } else { + toast.error('Billing portal is not available right now.') + } + } else { + toast.error('Failed to open billing portal.') + } + setOpeningPortal(false) + } + } + + if (isLoading && !subscription) { + return ( +
+ +
+ ) + } + + return ( + <> + +
+ {/* ── Header ─────────────────────────────────────────────────────── */} +
+
+ +

+ Billing +

+
+

+ Manage your subscription, payment method, and billing history. +

+
+ + {/* ── Past-due banner ────────────────────────────────────────────── */} + {isPastDue && ( +
+ +
+

Your last payment failed.

+

+ Update your payment method to keep access to ResolutionFlow. +

+
+ +
+ )} + + {/* ── Subscription summary card ──────────────────────────────────── */} +
+
+
+
+ + + {planBilling?.display_name ?? 'No active plan'} + +
+ {subscription && ( +
+ {statusLabel(subscription.status)} + {subscription.cancel_at_period_end && ' · cancels at period end'} +
+ )} +
+ {subscription?.seat_limit != null && ( +
+
Seats
+
+ {subscription.seat_limit} +
+
+ )} +
+ +
+
+
+ {isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'} +
+
+ {isComplimentary ? '—' : formatDate(subscription?.current_period_end)} +
+
+
+
Plan started
+
+ {formatDate(subscription?.current_period_start)} +
+
+
+ + {/* State-specific messaging ------------------------------------ */} + {isComplimentary && ( +
+ Complimentary Pro — no billing required. +
+ )} + + {isTrialing && ( +
+ Trial ends {formatDate(subscription?.current_period_end)} — pick a plan + to continue. +
+ )} + + {isCanceled && ( +
+ Subscription canceled. Reactivate by picking a plan. +
+ )} +
+ + {/* ── Actions ────────────────────────────────────────────────────── */} + {!isComplimentary && ( +
+ {(isTrialing || isCanceled) && ( + + Pick a plan + + )} + + {!isTrialing && !isCanceled && ( + + Change plan + + )} + + +
+ )} +
+ + ) +} + +export default BillingPage diff --git a/frontend/src/pages/account/SelectPlanPage.tsx b/frontend/src/pages/account/SelectPlanPage.tsx new file mode 100644 index 00000000..8075f649 --- /dev/null +++ b/frontend/src/pages/account/SelectPlanPage.tsx @@ -0,0 +1,354 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { Check, CreditCard, Loader2 } from 'lucide-react' + +import { billingApi } from '@/api/billing' +import { plansApi, type PublicPlanResponse } from '@/api/plans' +import { Button } from '@/components/ui/Button' +import { PageMeta } from '@/components/common/PageMeta' +import { useBillingStore } from '@/store/billingStore' +import { toast } from '@/lib/toast' +import { cn } from '@/lib/utils' +import type { BillingInterval, CheckoutPlan } from '@/types/billing' + +function formatPrice(cents: number | null | undefined): string { + if (cents == null) return '' + const dollars = cents / 100 + return `$${Math.round(dollars).toLocaleString()}` +} + +const PLAN_FALLBACK_FEATURES: Record = { + starter: ['AI Builder', 'Up to 1 seat', 'Email support'], + pro: [ + 'PSA Integration', + 'KB Accelerator', + 'AI Builder', + 'Priority support', + ], + team: [ + 'Everything in Pro', + 'Multi-seat collaboration', + 'Shared categories', + ], + enterprise: [ + 'Custom seats and SSO', + 'Custom branding', + 'Dedicated success contact', + ], +} + +interface PlanCardProps { + plan: PublicPlanResponse + interval: BillingInterval + isCurrent: boolean + isEnterprise: boolean + onSelect: (planKey: CheckoutPlan) => void + isSubmitting: boolean +} + +function PlanCard({ + plan, + interval, + isCurrent, + isEnterprise, + onSelect, + isSubmitting, +}: PlanCardProps) { + const planKey = plan.plan.toLowerCase() as CheckoutPlan + const cents = + interval === 'annual' ? plan.annual_price_cents : plan.monthly_price_cents + const features = PLAN_FALLBACK_FEATURES[planKey] ?? [] + + return ( +
+
+

+ {plan.display_name} +

+ {isCurrent && ( + + Current plan + + )} +
+ + {plan.description && ( +

{plan.description}

+ )} + +
+ {isEnterprise ? ( +
+ Custom pricing +
+ ) : cents != null ? ( +
+ + {formatPrice(cents)} + + + / {interval === 'annual' ? 'year' : 'month'} + +
+ ) : ( +
Contact us
+ )} +
+ +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ +
+ {isEnterprise ? ( + + Talk to sales + + ) : ( + + )} +
+
+ ) +} + +export function SelectPlanPage() { + const subscription = useBillingStore((s) => s.subscription) + const currentPlan = subscription?.plan ?? null + const isCurrentActive = + subscription?.status === 'active' || subscription?.status === 'trialing' + + const [plans, setPlans] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [interval, setInterval] = useState('monthly') + const [seats, setSeats] = useState(1) + const [submittingPlan, setSubmittingPlan] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + plansApi + .getPublic() + .then((data) => { + if (cancelled) return + // Sort by sort_order so the layout is stable. + const sorted = [...data].sort((a, b) => a.sort_order - b.sort_order) + setPlans(sorted) + setLoadError(null) + }) + .catch(() => { + if (cancelled) return + setLoadError('Unable to load plans. Please try again.') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const seedSeats = useMemo(() => { + return subscription?.seat_limit && subscription.seat_limit > 0 + ? subscription.seat_limit + : 1 + }, [subscription?.seat_limit]) + + useEffect(() => { + setSeats(seedSeats) + }, [seedSeats]) + + const handleSelectPlan = async (planKey: CheckoutPlan) => { + if (planKey === 'enterprise') return + setSubmittingPlan(planKey) + try { + const { url } = await billingApi.createCheckoutSession({ + plan: planKey, + seats: Math.max(1, Math.floor(seats)), + billing_interval: interval, + }) + window.location.href = url + } catch { + toast.error('Could not start checkout. Please try again.') + setSubmittingPlan(null) + } + } + + return ( + <> + +
+ {/* ── Header ─────────────────────────────────────────────────────── */} +
+
+ +

+ Pick a plan +

+
+

+ Choose the plan that fits your team. You can change or cancel any + time. +

+
+ + {/* ── Controls ───────────────────────────────────────────────────── */} +
+
+ + Billing interval + +
+ + +
+
+ +
+ + { + const next = Number.parseInt(e.target.value, 10) + if (Number.isFinite(next) && next >= 1) { + setSeats(next) + } else if (e.target.value === '') { + setSeats(1) + } + }} + className={cn( + 'mt-2 w-24 rounded-lg border border-border bg-card px-3 py-1.5', + 'text-sm text-foreground', + 'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20', + )} + /> +
+
+ + {/* ── Plan cards ─────────────────────────────────────────────────── */} + {loading && ( +
+ +
+ )} + + {loadError && !loading && ( +
+ {loadError} +
+ )} + + {!loading && !loadError && plans && ( +
+ {plans.map((plan) => { + const planKey = plan.plan.toLowerCase() + const isEnterprise = planKey === 'enterprise' + const isCurrent = !!( + isCurrentActive && + currentPlan && + currentPlan.toLowerCase() === planKey + ) + return ( + + ) + })} +
+ )} + +
+ + ← Back to billing + +
+
+ + ) +} + +export default SelectPlanPage diff --git a/frontend/src/pages/account/__tests__/BillingPage.test.tsx b/frontend/src/pages/account/__tests__/BillingPage.test.tsx new file mode 100644 index 00000000..49618471 --- /dev/null +++ b/frontend/src/pages/account/__tests__/BillingPage.test.tsx @@ -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 +const toastError = toast.error as unknown as ReturnType + +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( + + + , + ) +} + +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.', + ) + }) + }) +}) diff --git a/frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx b/frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx new file mode 100644 index 00000000..fb21eba6 --- /dev/null +++ b/frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx @@ -0,0 +1,178 @@ +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 +const getPublic = plansApi.getPublic as unknown as ReturnType + +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( + + + , + ) +} + +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() + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index df12a5ff..e535969a 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -100,6 +100,8 @@ const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsP const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage')) const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage')) const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage')) +const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage')) +const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage')) /** Wraps a lazy-loaded page with Suspense + ErrorBoundary */ function page(Component: React.LazyExoticComponent) { @@ -338,6 +340,8 @@ export const router = sentryCreateBrowserRouter([ ), }, + { path: 'billing', element: page(BillingPage) }, + { path: 'billing/select-plan', element: page(SelectPlanPage) }, ], }, ], diff --git a/frontend/src/types/billing.ts b/frontend/src/types/billing.ts index f0654038..377da4c1 100644 --- a/frontend/src/types/billing.ts +++ b/frontend/src/types/billing.ts @@ -49,3 +49,45 @@ export interface BillingStateApiResponse { plan_limits: Record enabled_features: Record } + +/* --------------------------------------------------------------------------- + * Checkout / Customer-Portal session types + * ------------------------------------------------------------------------- */ + +export type CheckoutPlan = 'starter' | 'pro' | 'team' | 'enterprise' +export type BillingInterval = 'monthly' | 'annual' + +export interface CheckoutSessionRequest { + plan: CheckoutPlan + seats: number + billing_interval: BillingInterval +} + +export interface CheckoutSessionResponse { + url: string +} + +export interface BillingPortalSessionResponse { + url: string +} + +/** + * Typed error codes returned by the portal-session endpoint when the call + * cannot succeed for a reason the UI should explain to the user. + * + * - `stripe_not_configured` (HTTP 503): Stripe isn't wired up server-side + * (rare — env-misconfig / dev mode). + * - `no_stripe_customer` (HTTP 400): The account has never been billed, so + * there's no Customer Portal session to open. UX: "Complete checkout + * first to access billing portal." + */ +export type BillingPortalErrorCode = 'stripe_not_configured' | 'no_stripe_customer' + +export class BillingPortalError extends Error { + code: BillingPortalErrorCode + constructor(code: BillingPortalErrorCode, message?: string) { + super(message ?? code) + this.name = 'BillingPortalError' + this.code = code + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5dc98ebb..af62b29c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -99,7 +99,14 @@ export type { PlanBillingState, BillingStatePayload, BillingStateApiResponse, + CheckoutPlan, + BillingInterval, + CheckoutSessionRequest, + CheckoutSessionResponse, + BillingPortalSessionResponse, + BillingPortalErrorCode, } from './billing' +export { BillingPortalError } from './billing' export * from './scripts' export * from './script-builder'