Skip to main content

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/resume POSTs owner-gated, CreateCheckoutSessionDto {plan, currency}), the unguarded pricing GET in src/portal/portal-pricing.controller.ts, no subscribe route in the controller, pnpm bootstrap:stripe-plans in package.json, org stripe_customer_id/paymob_customer_id columns in prisma/schema.prisma, the ops-guarded admin direct-plan route in src/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 — BillingConfigService fails fast; the working local set came from the Heroku optolink-test app).
  • pnpm prisma migrate reset + seed applied; server on :3000.
  • plan_config.stripePriceId backfilled for USD — the seed does not populate it. pnpm bootstrap:stripe-plans creates products/prices in Stripe test; or backfill the existing ids: price_1UE6Or… SOLO / price_1UE6Os… GROWTH / price_1UE6Ot… SCALE. Missing ids → Stripe checkout 400s Plan … 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 subscriptions row up front (so the activation webhook has a row to attach to) and sets stripe_customer_id / paymob_customer_id on 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/success page polls subscription/active for ~30 s, then falls back to "still processing" (recorded).
  • Read-only gate? Clean up after: psql "$DBURL" -c 'DELETE FROM subscriptions;' (test data only).