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>
80 lines
2.4 KiB
TypeScript
80 lines
2.4 KiB
TypeScript
import { AxiosError } from 'axios'
|
|
|
|
import apiClient from './client'
|
|
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
|
|
* into the camelCase shape used by the rest of the frontend.
|
|
*
|
|
* Keeping the transform here means the store, hooks, and components
|
|
* never see snake_case keys.
|
|
*/
|
|
function transformBillingState(raw: BillingStateApiResponse): BillingStatePayload {
|
|
return {
|
|
subscription: raw.subscription ?? null,
|
|
planBilling: raw.plan_billing ?? null,
|
|
planLimits: raw.plan_limits ?? {},
|
|
enabledFeatures: raw.enabled_features ?? {},
|
|
}
|
|
}
|
|
|
|
export const billingApi = {
|
|
async getState(): Promise<BillingStatePayload> {
|
|
const response = await apiClient.get<BillingStateApiResponse>('/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<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
|