Billing architecture
Scope: the billing v2 mechanism — how a checkout becomes an entitlement, how plan changes work per gateway, and why idempotency is invoice-keyed. The route table is REST surface; the daily crons (Paymob renewal, expiry, overage sweep) are Renewals & overage; the runnable gate is Checkout gate.
Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2, 2026-10-07 (
src/billing/subscriptions/checkout.service.ts,src/billing/subscriptions/plan-change.service.ts,src/billing/webhooks/webhook-transaction.ts,src/billing/config/billing-config.service.ts,src/billing/config/plans.config.ts) and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (src/lib/billing/geo.ts,src/hooks/use-subscription.ts). Live runtime behavior was last verified 2026-09-13 — see the checkout gate.
Model in one paragraph
Two real, hard-wired gateways — Stripe (USD) and Paymob (EGP, MOTO
renewal engine) — routed server-side by currency; there is no provider
abstraction, no env toggle, and the user never picks a gateway. Entitlements
are granted only by gateway webhooks (invoice.paid / Paymob
TRANSACTION callback), idempotent on Invoice.gatewayInvoiceId. Plan
lifecycle (upgrade with prorated charge, scheduled downgrade, cancel/resume)
is first-party in PlanChangeService for both gateways.
Currency routing
Backend: currency is the sole input — checkout.service.ts maps
EGP → PAYMOB, everything else → STRIPE.
Portal (src/lib/billing/geo.ts): currencyForCountry maps EG → EGP,
anything else (null included) → USD. Geo-detection is deliberately inert
(FLOW-012 D1): nothing writes the localStorage.userCountryCode cache and
cachedCountry() falls back to "EG", so every browser defaults to
EGP → Paymob; the geo-IP fetch (detectCountry) is retained but never
reached, and USD/Stripe is reachable only by manually setting the localStorage
key to a non-EG country. Changing the default is a product decision, not a
code fix. Portal hooks (use-subscription.ts): callers branch on the
returned action and redirect only for a checkout action with a non-null
checkoutUrl; useMyActiveSubscription has staleTime: 0 — activation is
webhook-driven and async, so the success page polls it.
Checkout session — first subscription only
CheckoutService.createCheckoutSession, reached from
POST /portal/billing/checkout-session:
- Only
CHECKOUT_PLANS(SOLO/GROWTH/SCALE) pass; STARTER → 400 (default tier), ENTERPRISE → 400 (admin-set). Stripe checkout additionally requiresplan_config.stripePriceId→ else 400 "Plan … is not available via Stripe"; EGP rows are always checkout-ready (MOTO derives the charge from the seeded amount). - A live Stripe subscription never checks out again — 400 pointing at
change-plan/cancel/resume (a second checkout would bill twice for
overlapping time). A live Paymob sub that is not winding down delegates
to
PlanChangeService.changePlan— so checkout-session can return any lifecycle action. Switching gateway on a live sub → 400 ("Cancel it before switching"). - A winding-down (cancel-pending) Paymob sub falls through to a fresh checkout: Paymob's cancel is permanent, so re-subscribing — even to the same plan — is a new subscription.
- An INACTIVE
Subscriptionrow is upserted up front (one per org) so the activation webhook has a row to attach to. While winding down,planis not overwritten (the customer is still entitled to what they paid for); the webhook writes the real plan when payment lands. - Paymob: the Intention API's order id is persisted as
Subscription.gatewaySubscriptionId— card-token callbacks carryorder_idbut don't echo the intention's extras, so this resolves the token callback to the org. Gateway API failures surface as 400 "Could not start checkout: …".
The action union
Both checkout-session and change-plan return the same CheckoutSessionResult
shape (mirrors PlanChangeResult): action, gateway, checkoutUrl,
effectiveAt, scheduledPlan, seatWarning.
| action | What happened | checkoutUrl |
|---|---|---|
CHECKOUT | Fresh subscription started; hosted checkout awaiting payment | hosted URL |
UPGRADED | Immediate upgrade, prorated charge applied server-side | null |
UPGRADE_CHECKOUT | Paymob upgrade with no saved card → 3DS checkout for the prorated difference; webhook applies it when it clears | hosted URL |
SCHEDULED_DOWNGRADE | Cheaper tier from period end (effectiveAt, scheduledPlan set) | null |
DOWNGRADE_CANCELLED | Re-picking the current plan released a scheduled downgrade | null |
seatWarning is computed for downgrades only (org members vs the target
plan's seats); it is always null on upgrades.
Plan lifecycle — one service, both gateways
PlanChangeService.changePlan requires a live (ACTIVE/PAST_DUE) row with a
gatewaySubscriptionId. Re-picking the current plan while a downgrade is
scheduled means "keep me where I am" — the only way to undo one:
Stripe releases the subscription schedule, Paymob just clears the mirror;
both return DOWNGRADE_CANCELLED. Same plan otherwise → 400 ("use resume"
when a Stripe cancellation is pending); a winding-down Paymob sub → 400
(resubscribe via checkout — letting an "upgrade" land would get the customer
cancelled right after paying).
- Upgrade — Stripe: repoint the subscription at the other price;
Stripe prorates and charges; applies immediately (
UPGRADED, no redirect). - Upgrade — Paymob: WE own it (no native plan object). Charge the saved
card the prorated difference — (Y − X) × remaining/cycle — apply the
plan immediately, keep the period boundary (
UPGRADED); the renewal cron collects the full new price at that boundary. No saved card →UPGRADE_CHECKOUT(3DS hosted checkout for the same prorated amount). - Downgrade — both: deferred to period end; the customer keeps the tier
they paid for. Paymob records
Subscription.scheduledPlan(the renewal cron collects it); Stripe uses subscription-schedule phase 2, mirrored intoscheduledPlanso the read path is uniform (scheduling also flipscancelAtPeriodEndback to false). Landed changes resolve through the webhooks — they stay the source of truth. - cancel stops billing at period end (idempotent; Paymob's is permanent). resume calls off a pending cancellation and charges nothing.
Webhook entitlement granting — invoice-keyed idempotency
There is no WebhookEvent table. Every entitlement-granting delivery goes
through runInvoicePaidTransaction (webhooks/webhook-transaction.ts),
which dedups on Invoice.gatewayInvoiceId @unique:
- Read-then-write inside the transaction: an existing PAID invoice → safe replay, no second grant; an existing non-PAID row (OPEN, or FAILED from a retried deduction) → transitioned to PAID and the grant still runs; absent → insert. Why not insert-then-catch-P2002: Postgres aborts the whole transaction on any statement error (25P02), so a mid-tx recovery read is impossible without SAVEPOINTs — which Prisma's interactive transactions don't expose. Insert-first produced a 500 on every replay, the gateway redelivered, and the same failure looped forever.
- Concurrent-duplicate race: two simultaneous deliveries can both read
"absent" and both insert; the unique index backstops, the loser's whole
transaction rolls back, and the P2002 is caught outside the tx and
treated as a safe replay (the winner's commit included the grant). The
catch matches only the invoice's
gateway_invoice_idindex — swallowing any other P2002 (e.g. the one-per-org subscription index) would silently skip a real grant. - In the same transaction: the subscription row is find-or-created
(self-heals webhook-beats-checkout ordering and migrated orgs);
Invoice.billedByis the acting user or, for gateway-driven grants, the org's OWNER (no OWNER → throw → HTTP 500 → gateway retries);Organization.planmirrors the lowercase plan key (the entitlements read path); the subscription flips ACTIVE with plan/period, andclearScheduledPlancommits with the plan write — a replay must never leave a scheduled downgrade erased while the row sits on the old plan.
Boot: BillingConfigService fail-fast
onModuleInit validates 10 required env vars and throws with the
complete missing list — empty string counts as missing: STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET, PAYMOB_SECRET_KEY,
PAYMOB_CARD_INTEGRATION_ID_WEB_3DS, PAYMOB_MOTO_INTEGRATION_ID,
PAYMOB_PUBLIC_KEY, PAYMOB_HMAC_SECRET, PAYMOB_API_KEY (a separate
credential from the secret key — it mints tokens for the subscription
management API), BACKEND_URL, CLIENT_URL. The two Paymob integration ids
are distinct dashboard integrations (3DS web checkout vs MOTO). Plan prices,
seats, and stripePriceId do not live in env — they are seeded into
plan_config and validated by PlanConfigService.