// Billing client: fetches subscription status, kicks off checkout/portal // flows, and owns the global "upgrade required" prompt shown by the // 402 interceptor in useApi. State is shared across components via // useState so the prompt can be triggered from any handler. export interface BillingStatus { tier: 'free' | 'pro' | 'business' status: string current_period_end?: string cancel_at_period_end: boolean limits: { events_per_month: number guests_per_event: number } usage: { events_this_month: number } portal_available: boolean } // UpgradePrompt mirrors the 402 body the backend returns when a limit is // hit. Shown globally as a modal until dismissed or acted upon. export interface UpgradePrompt { error: string reason: string tier: string used: number limit: number upgrade_url: string } const FREE_DEFAULT: BillingStatus = { tier: 'free', status: 'active', cancel_at_period_end: false, limits: { events_per_month: 1, guests_per_event: 50 }, usage: { events_this_month: 0 }, portal_available: false, } export function useBilling() { const status = useState('gg-billing-status', () => null) const loading = useState('gg-billing-loading', () => false) const prompt = useState('gg-upgrade-prompt', () => null) async function fetchStatus(): Promise { loading.value = true try { const res = await useApi('/billing/status') status.value = res return res } catch (e: any) { // 401/refresh edge → caller redirected to /login by useApi. If the // backend has billing wired but the response is malformed, fall // back to free defaults so the page renders something usable. status.value = FREE_DEFAULT return FREE_DEFAULT } finally { loading.value = false } } async function startCheckout(tier: 'pro' | 'business'): Promise { const res = await useApi<{ url: string }>('/billing/checkout-session', { method: 'POST', body: { tier }, }) if (import.meta.client) window.location.href = res.url } async function openPortal(): Promise { const res = await useApi<{ url: string }>('/billing/portal', { method: 'POST' }) if (import.meta.client) window.location.href = res.url } function showUpgradePrompt(info: UpgradePrompt) { prompt.value = info } function dismissUpgradePrompt() { prompt.value = null } return { status, loading, prompt, fetchStatus, startCheckout, openPortal, showUpgradePrompt, dismissUpgradePrompt, } } // Static pricing copy. Keep in sync with internal/billing/tiers.go. // One source of truth for the marketing-page-style cards in // /dashboard/billing and the UpgradeModal. export interface TierCard { id: 'free' | 'pro' | 'business' name: string price: string priceSubtitle: string tagline: string features: string[] highlight?: boolean } export const TIER_CARDS: TierCard[] = [ { id: 'free', name: 'Free', price: '$0', priceSubtitle: 'forever', tagline: 'Try GuestGuard with a single event.', features: [ '1 event per month', 'Up to 50 guests per event', 'Branded email invitations', 'Real-time RSVP dashboard', ], }, { id: 'pro', name: 'Pro', price: '$49', priceSubtitle: 'per month', tagline: 'For active hosts running several events.', features: [ '10 events per month', 'Up to 1,000 guests per event', 'WhatsApp + email invitations', 'CSV import + bulk send', 'Priority email support', ], highlight: true, }, { id: 'business', name: 'Business', price: '$199', priceSubtitle: 'per month', tagline: 'For agencies and corporate events teams.', features: [ 'Unlimited events', 'Up to 5,000 guests per event', 'Everything in Pro', 'Signed DPA on request', 'SLA with response targets', ], }, ]