/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>
252 lines
9.5 KiB
TypeScript
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 };
|
|
}
|