Files
server-26/drb-frontend/lib/billing.ts
T
Logan Cusano 53965e1a19
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m35s
Rebuild the frontend as a product rather than an internal tool
The UI worked but read as an operator console: no public face, no way to
describe or sell the thing, and no account surface beyond the node list. This
adds the missing halves and reorganises what was already there around the
incident, which is the unit of value the rest of the pipeline is built to
produce.

A shared design system replaces per-page styling: components/ui (Button, Card,
Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the
Tailwind config, and light-mode tokens in globals.css. The existing
html:not(.dark) remap mechanism is extended rather than replaced -- a parallel
theming system would have been two sources of truth for the same colours.

Public marketing pages (/, /features, /pricing, /faq) load without a session.
middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX
redirect and is still NOT an authorisation boundary, which the comment there
says explicitly. Real enforcement is unchanged and still lives server-side in
c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher
instead of by route group, because a route group would have collided on / and
forced most of app/ to move for no behavioural gain.

Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and
lib/apiKeys.ts define the data model and the screens consume it, but every
mutating call throws with a message naming the backend route that has to exist
first, and the sample data is labelled as sample. Nothing here can charge
anyone or mint a real credential -- picking a payment processor and holding its
keys is a decision for a human, and a half-wired checkout is worse than an
obviously absent one.

The severity work from the c2-core change lands here too. severity is now a
filter and sort dimension on the incident list rather than decoration, since
a busy dispatch channel is only readable if you can collapse it to moderate and
above. routine gets a muted treatment because it is the majority of traffic,
legacy "unknown" still renders nothing, and TypeBadge handles the new "other"
incident type. Severity rendering moved into lib/severity.tsx so the incident
list, incident detail and call rows cannot drift apart.

Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and
admin. They already share the palette and stay coherent, and rewriting them
would have buried the parts that actually needed to change. No colour tokens
were renamed, so nothing regressed there.

Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification
was possible and none was done. No new environment variables.
2026-08-16 19:34:47 -04:00

236 lines
8.6 KiB
TypeScript

/**
* 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<Subscription> {
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<UsageSummary> {
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<Invoice[]> {
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 };
}