Skip to main content

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 requires plan_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 Subscription row is upserted up front (one per org) so the activation webhook has a row to attach to. While winding down, plan is 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 carry order_id but 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.

actionWhat happenedcheckoutUrl
CHECKOUTFresh subscription started; hosted checkout awaiting paymenthosted URL
UPGRADEDImmediate upgrade, prorated charge applied server-sidenull
UPGRADE_CHECKOUTPaymob upgrade with no saved card → 3DS checkout for the prorated difference; webhook applies it when it clearshosted URL
SCHEDULED_DOWNGRADECheaper tier from period end (effectiveAt, scheduledPlan set)null
DOWNGRADE_CANCELLEDRe-picking the current plan released a scheduled downgradenull

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 into scheduledPlan so the read path is uniform (scheduling also flips cancelAtPeriodEnd back 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_id index — 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.billedBy is the acting user or, for gateway-driven grants, the org's OWNER (no OWNER → throw → HTTP 500 → gateway retries); Organization.plan mirrors the lowercase plan key (the entitlements read path); the subscription flips ACTIVE with plan/period, and clearScheduledPlan commits 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.