Billing REST surface
Scope: which billing routes exist, who may call them, what they return, and which PAYG-era routes are gone. The mechanism behind each action is architecture and renewals & overage; the runnable gate is checkout-gate.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (
src/portal/portal-billing.controller.ts,src/portal/portal-pricing.controller.ts,src/billing/controllers/billing-admin.controller.ts,src/billing/webhooks/*,src/billing/services/overage-billing.service.ts,src/billing/schedulers/). Status-code claims are code-read only.
Customer surface (/portal/billing/*, owner-gated unless noted)
| Method | Path | Success | Notes |
|---|---|---|---|
| GET | /portal/billing/pricing?currency=USD|EGP | 200 PlanPrice[] | Public — no guard at all (the unauthenticated pricing page renders tiers first; payload is display data, gateway price/plan ids never leave the server). Backed by seeded plan_config; ENTERPRISE shows amount 0 ("Custom") |
| POST | /portal/billing/checkout-session | 200 CheckoutSessionResult | First subscription only (live Paymob delegates to change-plan — see architecture). Creates an INACTIVE Subscription row up front so the activation webhook has a row to attach to. STARTER → 400 (default tier), ENTERPRISE → 400 (not self-serve) |
| POST | /portal/billing/change-plan | 200 CheckoutSessionResult | Live sub: UPGRADED / UPGRADE_CHECKOUT / SCHEDULED_DOWNGRADE / DOWNGRADE_CANCELLED — action union |
| POST | /portal/billing/cancel · /resume | 200 | |
| GET | /portal/billing/subscription/active | 200 { subscription } | Envelope — the checkout success page polls this (webhook activates async) |
| GET | /portal/billing/subscription | 200 | |
| GET | /portal/billing/invoices (+/:id) | 200 paginated | Minor-unit amounts; rows are gateway PAID records |
| GET | /portal/billing/payment-methods | 200 | Paymob saved card tokens |
| POST | /portal/billing/portal-session | 200 {url} | Stripe hosted billing portal (card management; lifecycle is first-party) |
Admin surface (ops)
| Route | Notes |
|---|---|
POST /portal/admin/billing/organizations/:orgId/plan | Direct plan set, no checkout/gateway (ops-role guarded): upserts Subscription + mirrors Organization.plan in one transaction; gateway sentinel STRIPE (never renewed); clears scheduledPlan/cancelAtPeriodEnd |
Removed with the PAYG engine (expect 404)
/portal/billing/subscribe, /dev/billing/* (sandbox billing triggers
deleted — src/sandbox/ keeps only the deferred-deep-link simulator),
/portal/admin/billing/invoices, /portal/admin/billing/accounts/:orgId,
/portal/admin/billing/configs, /portal/billing/summary. The old invoice
waive/retry semantics died with the PAYG Invoice model.
Gateway receivers
POST /webhook/stripe (signature-verified) and POST /webhook/paymob?hmac=…
(HMAC-verified) — not Clerk-authed, @ApiExcludeEndpoint'd: the signature /
HMAC is the auth gate. These are the only writers that activate a
subscription and grant entitlements (the admin route is the one exception,
by design).
Gotchas (fixture & query level)
- Billing v2 tables are snake_case (
subscriptions,plan_config,gateway_subscription_id,stripe_customer_id); legacy tables keep PascalCase ("Organization","User"). Checkinformation_schema.columnsbefore writing raw SQL — don't assume either convention globally. subscriptionshas no money columns — pricing lives inplan_config. Usable columns:id, organization_id, plan, status, gateway, gateway_subscription_id, gateway_customer_id, current_period_start, current_period_end, cancel_at_period_end, scheduled_plan, created_at, updated_at. A minimal fakeACTIVErow (plan, status, gateway, gateway_subscription_id+ period timestamps) is enough to trip guards keyed on subscription state (e.g. the delete-org 409).- Two ledgers, on purpose: quota usage (
checkQuota('clicks_per_month')) countsClickEventrows directly; the overage sweep bills uninvoicedUsageLedgerrows (written byrecordUsagewithreferenceType: 'ClickEvent'). When touching the sweep, keep its org +invoicedAt: null+createdAt ≤ periodEndfilter intact — renewals & overage documents why. - The
billing_v2_subscription_portmigration is destructive (drops the PAYG tables). Safe on empty/reset DBs;pg_dumpto.backups/first when the data matters, and prefermigrate resetovermigrate deployon disposable data.