Files
server-26/drb-frontend/lib/billing.ts
T
DRB CEO agentandClaude Opus 5 a1bdccff45
Build & Deploy / Build & push images (push) Successful in 4m59s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped
Gate A: take invented prices off every public surface
/pricing and the homepage teaser rendered the $0/$79/Custom catalog from
lib/billing.ts with a below-the-fold disclaimer. Board minutes #42 ratified
Gate A: no price on a public surface until the model is ratified and the
entitlements exist — a false price anchor with a footnote is worse than no
price. The page has been live in breach since ratification (server-26#46).

- /pricing: no numbers, no plan cards, no interval toggle. "Pricing is in
  development", CTA to the existing /waitlist request-access page.
- homepage: pricing teaser replaced with the same message; PLANS import gone.
- homepage CTAs pointed at /login, which has no signup path — a real visitor
  could not create an account. Now /waitlist ("Request access"); the secondary
  CTA is honestly labelled "Sign in".
- lib/billing.ts: plan catalog header now states the prices are invented and
  that retention/SSO/SLA have no backend, so the next person to import PLANS
  is warned at the definition site.

Refs server-26#46, server-26#62. Authenticated /settings/billing is unchanged
and still stubbed — not a public price surface, stays with #46.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:34:46 -04:00

252 lines
9.5 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 — NOT SAFE TO SHIP. Do not put these on a public surface.
//
// These prices are INVENTED. BUSINESS_MODEL.md §3.1 (ratified in structure by
// board minutes #42, 2026-08-23) marks $0/$79/custom as superseded, and the
// board ruled that pricing comes OFF the public site entirely — replaced with
// "Pricing in development — contact us" — rather than kept behind a
// below-the-fold disclaimer. A false price anchor with a footnote is worse
// than no price. Tracked in server-26#46 (Gate A, due 2026-09-13).
//
// Also unbacked by any implementation, and each is its own claim-vs-reality
// gap if displayed:
// - retentionDays 7/90/365 — no TTL and no deletion sweep exists anywhere
// in drb-c2-core. server-26#44 (Gate B4).
// - SSO/SAML, uptime SLA, custom data residency — no backend at all.
// There are also zero backend billing routes; createCheckoutSession() and
// createBillingPortalSession() always throw. See ADMIN_BILLING_AUDIT.md §2.
//
// If pricing is ever wired for real, fetch it from the processor (Stripe
// Prices API) rather than hardcoding 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 };
}