Billing checkout gate
Scope: the verified curl sequence to run after any billing change before marking a task done. Not the architecture (→ Billing architecture), not how tokens are minted (→ Real-token HTTP).
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (re-check; HEAD unchanged since the 2026-10-06 pass): routes and DTOs in
src/portal/portal-billing.controller.ts(checkout-session/change-plan/cancel/resumePOSTs owner-gated,CreateCheckoutSessionDto {plan, currency}), the unguarded pricingGETinsrc/portal/portal-pricing.controller.ts, nosubscriberoute in the controller,pnpm bootstrap:stripe-plansinpackage.json, orgstripe_customer_id/paymob_customer_idcolumns inprisma/schema.prisma, the ops-guarded admin direct-plan route insrc/billing/controllers/billing-admin.controller.ts. The recorded response bodies (checkout URLs,{"subscription":null}) are from the 2026-09-13 post-merge gate run; re-verify live on next use.
Preconditions
- The 10 billing env vars are in
.env(required at boot —BillingConfigServicefails fast; the working local set came from the Herokuoptolink-testapp). pnpm prisma migrate reset+ seed applied; server on :3000.plan_config.stripePriceIdbackfilled for USD — the seed does not populate it.pnpm bootstrap:stripe-planscreates products/prices in Stripe test; or backfill the existing ids:price_1UE6Or…SOLO /price_1UE6Os…GROWTH /price_1UE6Ot…SCALE. Missing ids → Stripe checkout 400sPlan … is not available via Stripe.- Token: demo@ + Demo Org works (seeded OWNER) — mint per real-token-http. Checkout is first-subscription-only; on a live subscription it 400s (reset the DB or use the admin direct-plan route).
The sequence
TOK=$(cat /tmp/tok.txt) # or mint fresh per real-token-http.md
# pricing (both currencies — public surface, PlanConfig-backed)
curl -s "localhost:3000/portal/billing/pricing?currency=EGP" -H "Authorization: Bearer $TOK"
# Paymob checkout (EGP) — real hosted checkout, test keys
curl -s -X POST localhost:3000/portal/billing/checkout-session \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"plan":"SOLO","currency":"EGP"}'
# → {checkoutUrl: "https://accept.paymob.com/unifiedcheckout/?publicKey=egy_pk_test_…",
# gateway: "PAYMOB", action: "CHECKOUT"} (recorded 2026-09-13)
# Stripe checkout (USD) — needs plan_config.stripePriceId backfilled (above)
curl -s -X POST localhost:3000/portal/billing/checkout-session \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"plan":"GROWTH","currency":"USD"}'
# → {checkoutUrl: "https://checkout.stripe.com/c/pay/cs_test_…",
# gateway: "STRIPE", action: "CHECKOUT"} (recorded 2026-09-13)
# reads
curl -s localhost:3000/portal/billing/subscription/active -H "Authorization: Bearer $TOK" # {"subscription":null} fresh org
curl -s localhost:3000/portal/billing/invoices -H "Authorization: Bearer $TOK"
curl -s localhost:3000/portal/billing/payment-methods -H "Authorization: Bearer $TOK"
# old PAYG contract must be dead
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:3000/portal/billing/subscribe \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"plan":"growth"}' # → 404
# plan lifecycle (code-verified routes; response bodies never recorded —
# expect the action union from architecture.md, needs a live subscription:
# activate via a gateway→localhost tunnel, or fake one via the admin route)
curl -s -X POST localhost:3000/portal/admin/billing/organizations/$ORG_ID/plan \
-H "Authorization: Bearer $OPS_TOK" -H 'Content-Type: application/json' \
-d '{"plan":"SOLO"}' # ops-org session, not the demo OWNER token
curl -s -X POST localhost:3000/portal/billing/change-plan \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"plan":"GROWTH"}'
curl -s -X POST localhost:3000/portal/billing/cancel -H "Authorization: Bearer $TOK"
curl -s -X POST localhost:3000/portal/billing/resume -H "Authorization: Bearer $TOK"
Beyond the local gate
Activation, the plan-change actions' real outcomes, MOTO renewals, and overage charges all depend on gateway webhooks reaching the backend — without a gateway→localhost tunnel they can only be exercised against a deployed backend. Everything above is runnable locally.
Side effects and activation
- A successful checkout-session call writes an INACTIVE
subscriptionsrow up front (so the activation webhook has a row to attach to) and setsstripe_customer_id/paymob_customer_idon the org. - Activation is webhook-driven: in local dev without a
gateway→localhost tunnel the subscription stays INACTIVE — the hosted
checkout cannot reach
/webhook/*. The portal's/subscribe/:organizationId/successpage pollssubscription/activefor ~30 s, then falls back to "still processing" (recorded). - Read-only gate? Clean up after:
psql "$DBURL" -c 'DELETE FROM subscriptions;'(test data only).