Skip to main content

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)​

MethodPathSuccessNotes
GET/portal/billing/pricing?currency=USD|EGP200 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-session200 CheckoutSessionResultFirst 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-plan200 CheckoutSessionResultLive sub: UPGRADED / UPGRADE_CHECKOUT / SCHEDULED_DOWNGRADE / DOWNGRADE_CANCELLED — action union
POST/portal/billing/cancel · /resume200
GET/portal/billing/subscription/active200 { subscription }Envelope — the checkout success page polls this (webhook activates async)
GET/portal/billing/subscription200
GET/portal/billing/invoices (+/:id)200 paginatedMinor-unit amounts; rows are gateway PAID records
GET/portal/billing/payment-methods200Paymob saved card tokens
POST/portal/billing/portal-session200 {url}Stripe hosted billing portal (card management; lifecycle is first-party)

Admin surface (ops)​

RouteNotes
POST /portal/admin/billing/organizations/:orgId/planDirect 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"). Check information_schema.columns before writing raw SQL — don't assume either convention globally.
  • subscriptions has no money columns — pricing lives in plan_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 fake ACTIVE row (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')) counts ClickEvent rows directly; the overage sweep bills uninvoiced UsageLedger rows (written by recordUsage with referenceType: 'ClickEvent'). When touching the sweep, keep its org + invoicedAt: null + createdAt ≤ periodEnd filter intact — renewals & overage documents why.
  • The billing_v2_subscription_port migration is destructive (drops the PAYG tables). Safe on empty/reset DBs; pg_dump to .backups/ first when the data matters, and prefer migrate reset over migrate deploy on disposable data.