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:
@@ -1,5 +1,15 @@
|
|||||||
|
import { AxiosError } from 'axios'
|
||||||
|
|
||||||
import apiClient from './client'
|
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
|
* Single boundary where the snake_case backend payload is transformed
|
||||||
@@ -22,6 +32,48 @@ export const billingApi = {
|
|||||||
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
|
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
|
||||||
return transformBillingState(response.data)
|
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<BillingPortalSessionResponse> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<BillingPortalSessionResponse>(
|
||||||
|
'/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<CheckoutSessionResponse> {
|
||||||
|
const response = await apiClient.post<CheckoutSessionResponse>(
|
||||||
|
'/billing/checkout-session',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default billingApi
|
export default billingApi
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
|
CreditCard,
|
||||||
Crown,
|
Crown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
|
|||||||
title="Profile"
|
title="Profile"
|
||||||
description="Your name, email, and personal preferences"
|
description="Your name, email, and personal preferences"
|
||||||
/>
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/account/billing"
|
||||||
|
icon={<CreditCard className="h-4 w-4" />}
|
||||||
|
title="Billing"
|
||||||
|
description="Subscription, payment method, and invoices"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAccountOwner && (
|
{isAccountOwner && (
|
||||||
|
|||||||
267
frontend/src/pages/account/BillingPage.tsx
Normal file
267
frontend/src/pages/account/BillingPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Billing" />
|
||||||
|
<div>
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||||
|
Billing
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Manage your subscription, payment method, and billing history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Past-due banner ────────────────────────────────────────────── */}
|
||||||
|
{isPastDue && (
|
||||||
|
<div
|
||||||
|
data-testid="past-due-banner"
|
||||||
|
className={cn(
|
||||||
|
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
|
||||||
|
'bg-warning-dim p-4 text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">Your last payment failed.</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Update your payment method to keep access to ResolutionFlow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
loading={openingPortal}
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
data-testid="past-due-update-payment"
|
||||||
|
>
|
||||||
|
Update payment method
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Subscription summary card ──────────────────────────────────── */}
|
||||||
|
<div className="card-flat max-w-xl space-y-5 p-6">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{planBilling?.display_name ?? 'No active plan'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{subscription && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-xs',
|
||||||
|
statusToneClass(subscription.status),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{statusLabel(subscription.status)}
|
||||||
|
{subscription.cancel_at_period_end && ' · cancels at period end'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subscription?.seat_limit != null && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-muted-foreground">Seats</div>
|
||||||
|
<div className="text-sm tabular-nums text-foreground">
|
||||||
|
{subscription.seat_limit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm tabular-nums text-foreground">
|
||||||
|
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Plan started</div>
|
||||||
|
<div className="text-sm tabular-nums text-foreground">
|
||||||
|
{formatDate(subscription?.current_period_start)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State-specific messaging ------------------------------------ */}
|
||||||
|
{isComplimentary && (
|
||||||
|
<div
|
||||||
|
data-testid="complimentary-message"
|
||||||
|
className="rounded-md bg-success-dim p-3 text-xs text-success"
|
||||||
|
>
|
||||||
|
Complimentary Pro — no billing required.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTrialing && (
|
||||||
|
<div
|
||||||
|
data-testid="trial-message"
|
||||||
|
className="rounded-md bg-info-dim p-3 text-xs text-info"
|
||||||
|
>
|
||||||
|
Trial ends {formatDate(subscription?.current_period_end)} — pick a plan
|
||||||
|
to continue.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCanceled && (
|
||||||
|
<div
|
||||||
|
data-testid="canceled-message"
|
||||||
|
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Subscription canceled. Reactivate by picking a plan.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions ────────────────────────────────────────────────────── */}
|
||||||
|
{!isComplimentary && (
|
||||||
|
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
|
||||||
|
{(isTrialing || isCanceled) && (
|
||||||
|
<Link
|
||||||
|
to="/account/billing/select-plan"
|
||||||
|
data-testid="select-plan-link"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
|
||||||
|
'text-sm font-semibold text-white hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Pick a plan
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isTrialing && !isCanceled && (
|
||||||
|
<Link
|
||||||
|
to="/account/billing/select-plan"
|
||||||
|
data-testid="change-plan-link"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-lg border border-border',
|
||||||
|
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||||
|
'hover:border-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Change plan
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
loading={openingPortal}
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
data-testid="manage-billing-button"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Manage billing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BillingPage
|
||||||
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
354
frontend/src/pages/account/SelectPlanPage.tsx
Normal file
@@ -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<string, string[]> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
data-testid={`plan-card-${planKey}`}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-4 rounded-xl border p-6',
|
||||||
|
isCurrent
|
||||||
|
? 'border-primary/40 bg-primary/5'
|
||||||
|
: 'border-border bg-card hover:border-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
{plan.display_name}
|
||||||
|
</h3>
|
||||||
|
{isCurrent && (
|
||||||
|
<span
|
||||||
|
data-testid={`plan-current-${planKey}`}
|
||||||
|
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||||
|
>
|
||||||
|
Current plan
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{plan.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-[3rem]">
|
||||||
|
{isEnterprise ? (
|
||||||
|
<div className="text-base font-medium text-foreground">
|
||||||
|
Custom pricing
|
||||||
|
</div>
|
||||||
|
) : cents != null ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-3xl font-bold text-foreground">
|
||||||
|
{formatPrice(cents)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-sm text-muted-foreground">
|
||||||
|
/ {interval === 'annual' ? 'year' : 'month'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">Contact us</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-2">
|
||||||
|
<Check className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
{isEnterprise ? (
|
||||||
|
<Link
|
||||||
|
to="/contact-sales"
|
||||||
|
data-testid={`plan-cta-${planKey}`}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex w-full items-center justify-center rounded-lg border border-border',
|
||||||
|
'bg-input px-4 py-2 text-sm font-medium text-foreground',
|
||||||
|
'hover:border-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Talk to sales
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
data-testid={`plan-cta-${planKey}`}
|
||||||
|
disabled={isCurrent || isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={() => onSelect(planKey)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isCurrent ? 'Current plan' : 'Continue to checkout'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PublicPlanResponse[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const [interval, setInterval] = useState<BillingInterval>('monthly')
|
||||||
|
const [seats, setSeats] = useState<number>(1)
|
||||||
|
const [submittingPlan, setSubmittingPlan] = useState<CheckoutPlan | null>(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 (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Pick a plan" />
|
||||||
|
<div>
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
|
||||||
|
Pick a plan
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Choose the plan that fits your team. You can change or cancel any
|
||||||
|
time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Controls ───────────────────────────────────────────────────── */}
|
||||||
|
<div className="mb-6 flex flex-wrap items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<span className="block text-xs font-medium text-muted-foreground">
|
||||||
|
Billing interval
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Billing interval"
|
||||||
|
className="mt-2 inline-flex rounded-lg border border-border bg-card p-1 text-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={interval === 'monthly'}
|
||||||
|
data-testid="interval-monthly"
|
||||||
|
onClick={() => setInterval('monthly')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 font-medium',
|
||||||
|
interval === 'monthly'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={interval === 'annual'}
|
||||||
|
data-testid="interval-annual"
|
||||||
|
onClick={() => setInterval('annual')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 font-medium',
|
||||||
|
interval === 'annual'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Annual
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="seats-input"
|
||||||
|
className="block text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Seats
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="seats-input"
|
||||||
|
data-testid="seats-input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={seats}
|
||||||
|
onChange={(e) => {
|
||||||
|
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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Plan cards ─────────────────────────────────────────────────── */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadError && !loading && (
|
||||||
|
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-sm text-danger">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !loadError && plans && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{plans.map((plan) => {
|
||||||
|
const planKey = plan.plan.toLowerCase()
|
||||||
|
const isEnterprise = planKey === 'enterprise'
|
||||||
|
const isCurrent = !!(
|
||||||
|
isCurrentActive &&
|
||||||
|
currentPlan &&
|
||||||
|
currentPlan.toLowerCase() === planKey
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<PlanCard
|
||||||
|
key={plan.plan}
|
||||||
|
plan={plan}
|
||||||
|
interval={interval}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
|
onSelect={handleSelectPlan}
|
||||||
|
isSubmitting={submittingPlan === planKey}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
to="/account/billing"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← Back to billing
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectPlanPage
|
||||||
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.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
178
frontend/src/pages/account/__tests__/SelectPlanPage.test.tsx
Normal file
@@ -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<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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -100,6 +100,8 @@ const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsP
|
|||||||
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||||
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
|
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
|
||||||
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
|
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 */
|
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
|
||||||
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
|
||||||
@@ -338,6 +340,8 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{ path: 'billing', element: page(BillingPage) },
|
||||||
|
{ path: 'billing/select-plan', element: page(SelectPlanPage) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,3 +49,45 @@ export interface BillingStateApiResponse {
|
|||||||
plan_limits: Record<string, unknown>
|
plan_limits: Record<string, unknown>
|
||||||
enabled_features: Record<string, boolean>
|
enabled_features: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,7 +99,14 @@ export type {
|
|||||||
PlanBillingState,
|
PlanBillingState,
|
||||||
BillingStatePayload,
|
BillingStatePayload,
|
||||||
BillingStateApiResponse,
|
BillingStateApiResponse,
|
||||||
|
CheckoutPlan,
|
||||||
|
BillingInterval,
|
||||||
|
CheckoutSessionRequest,
|
||||||
|
CheckoutSessionResponse,
|
||||||
|
BillingPortalSessionResponse,
|
||||||
|
BillingPortalErrorCode,
|
||||||
} from './billing'
|
} from './billing'
|
||||||
|
export { BillingPortalError } from './billing'
|
||||||
|
|
||||||
export * from './scripts'
|
export * from './scripts'
|
||||||
export * from './script-builder'
|
export * from './script-builder'
|
||||||
|
|||||||
Reference in New Issue
Block a user