/** * Billing/licensing boundary — STUB MODULE. * * This file defines the typed shape the settings UI (app/settings/billing) talks to. * Nothing here calls a real payment processor. Every exported function returns * hardcoded sample data or throws, and is marked with a TODO describing exactly * what a real integration would do. * * Do NOT wire a real Stripe (or other) publishable/secret key into this file. * When a processor is chosen: * 1. Add a c2-core router (e.g. `routers/billing.py`) that owns all server-side * calls to the processor's API using a secret key from server env — never * exposed to the frontend. * 2. Add a Stripe (or similar) webhook endpoint on c2-core that keeps an * `organizations/{orgId}` Firestore doc in sync with subscription state * (plan, status, current_period_end, seats, node_limit). * 3. Replace the bodies below with `c2api`-style `fetch` calls into that router. * Checkout/portal functions should return a redirect URL from a real * Checkout/Billing Portal session — the frontend's only job is * `window.location.href = url`, it should never touch card data directly. */ export type PlanId = "free" | "pro" | "enterprise"; export type SubscriptionStatus = "trialing" | "active" | "past_due" | "canceled" | "none"; export type BillingInterval = "monthly" | "annual"; export interface PlanLimits { seats: number | "unlimited"; nodes: number | "unlimited"; retentionDays: number; } export interface PlanDefinition { id: PlanId; name: string; tagline: string; priceMonthlyUsd: number | null; // null = "contact us" priceAnnualUsd: number | null; limits: PlanLimits; features: string[]; highlighted?: boolean; } export interface Subscription { planId: PlanId; status: SubscriptionStatus; interval: BillingInterval; currentPeriodEnd: string | null; // ISO date cancelAtPeriodEnd: boolean; trialEndsAt: string | null; // ISO date seatsUsed: number; nodesUsed: number; } export interface Invoice { id: string; date: string; // ISO date amountUsd: number; status: "paid" | "open" | "void" | "uncollectible"; description: string; /** In a real integration, a short-lived link to the processor-hosted PDF/receipt. */ hostedUrl: string | null; } export interface UsageSummary { seatsUsed: number; seatsLimit: number | "unlimited"; nodesUsed: number; nodesLimit: number | "unlimited"; periodStart: string; periodEnd: string; } // --------------------------------------------------------------------------- // Plan catalog — this is real UI copy (safe to ship), just not wired to a // live pricing table. In a real integration this would likely be fetched // from the processor (Stripe Prices API) instead of hardcoded here so price // changes don't require a frontend deploy. // --------------------------------------------------------------------------- export const PLANS: PlanDefinition[] = [ { id: "free", name: "Community", tagline: "For a single node and a small crew keeping an eye on local traffic.", priceMonthlyUsd: 0, priceAnnualUsd: 0, limits: { seats: 3, nodes: 1, retentionDays: 7 }, features: [ "1 field node", "3 team seats", "Live incident map", "7-day call & incident history", "Discord voice relay", ], }, { id: "pro", name: "Pro", tagline: "For agencies and serious hobbyist networks running multiple nodes.", priceMonthlyUsd: 79, priceAnnualUsd: 790, limits: { seats: 15, nodes: 10, retentionDays: 90 }, features: [ "Up to 10 field nodes", "15 team seats", "AI incident correlation & summaries", "90-day call & incident history", "Alert rules with Discord webhooks", "API key access", ], highlighted: true, }, { id: "enterprise", name: "Enterprise", tagline: "For regional networks with custom retention, SSO, and support needs.", priceMonthlyUsd: null, priceAnnualUsd: null, limits: { seats: "unlimited", nodes: "unlimited", retentionDays: 365 }, features: [ "Unlimited field nodes", "Unlimited team seats", "1-year+ retention (custom)", "SSO / SAML", "Dedicated support & uptime SLA", "Custom data residency", ], }, ]; export function getPlan(id: PlanId): PlanDefinition { return PLANS.find((p) => p.id === id) ?? PLANS[0]; } // --------------------------------------------------------------------------- // Sample account state — clearly a demo fixture, not a real customer record. // TODO(billing): replace with `c2api.getSubscription()` once c2-core exposes // GET /org/subscription backed by the processor + Firestore org doc. // --------------------------------------------------------------------------- const SAMPLE_SUBSCRIPTION: Subscription = { planId: "pro", status: "trialing", interval: "monthly", currentPeriodEnd: new Date(Date.now() + 1000 * 60 * 60 * 24 * 21).toISOString(), cancelAtPeriodEnd: false, trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(), seatsUsed: 4, nodesUsed: 2, }; const SAMPLE_INVOICES: Invoice[] = [ { id: "sample_inv_1003", date: "2026-07-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null }, { id: "sample_inv_1002", date: "2026-06-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null }, { id: "sample_inv_1001", date: "2026-05-16", amountUsd: 0, status: "paid", description: "Community plan", hostedUrl: null }, ]; /** * TODO(billing): replace with `c2api.getSubscription()` → GET /org/subscription. * Returns sample data so the settings UI has something real to render today. */ export async function getCurrentSubscription(): Promise { return SAMPLE_SUBSCRIPTION; } /** * TODO(billing): replace with `c2api.getUsageSummary()` → GET /org/usage, * computed server-side from `nodes` count + org member count. */ export async function getUsageSummary(): Promise { const sub = await getCurrentSubscription(); const plan = getPlan(sub.planId); const now = new Date(); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); return { seatsUsed: sub.seatsUsed, seatsLimit: plan.limits.seats, nodesUsed: sub.nodesUsed, nodesLimit: plan.limits.nodes, periodStart: periodStart.toISOString(), periodEnd: periodEnd.toISOString(), }; } /** * TODO(billing): replace with `c2api.getInvoices()` → GET /org/invoices, * which on the backend would list Stripe Invoices for the org's customer id * and map them to this shape (hostedUrl = Stripe's `hosted_invoice_url`). */ export async function getInvoices(): Promise { return SAMPLE_INVOICES; } /** * TODO(billing): replace with `c2api.createCheckoutSession(planId, interval)` * → POST /org/billing/checkout-session, which creates a Stripe Checkout * Session server-side (secret key never leaves the server) and returns * `{ url }`. Frontend then does `window.location.href = url`. * * Throws here — there is no live checkout to redirect to. */ export async function createCheckoutSession(_planId: PlanId, _interval: BillingInterval): Promise<{ url: string }> { throw new Error( "Checkout is not wired to a payment processor yet. This is a demo build — " + "no card will be charged. See lib/billing.ts for the integration TODO." ); } /** * TODO(billing): replace with `c2api.createBillingPortalSession()` → * POST /org/billing/portal-session, which creates a Stripe Billing Portal * session server-side and returns `{ url }` for redirect. The portal is * where a real integration would let customers update payment methods, * cancel, or download invoices — avoids building that UI ourselves. */ export async function createBillingPortalSession(): Promise<{ url: string }> { throw new Error( "Billing portal is not wired to a payment processor yet. See lib/billing.ts for the integration TODO." ); } /** * TODO(billing): replace with `c2api.previewPlanChange(planId, interval)` → * GET /org/billing/preview?plan=…, which on the backend would call the * processor's upcoming-invoice/proration preview endpoint. * Returns a rough client-side estimate so the upgrade/downgrade UI has * something to show; not a real proration calculation. */ export async function previewPlanChange(planId: PlanId, interval: BillingInterval): Promise<{ dueTodayUsd: number; nextAmountUsd: number }> { const plan = getPlan(planId); const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd; return { dueTodayUsd: price ?? 0, nextAmountUsd: price ?? 0 }; }