Architecture
Billing — Architecture
Billing is gateway-agnostic: one interface (IPaymentGateway), pluggable
adapters. The subscription is the state machine; the payment gateway only moves
money and reports back over a webhook. All billing state lives in the MAIN
database, keyed by user_workspace_id — not in the hyper/tenant DB.
Gateway selection is runtime, via the
PAYMENT_GATEWAYenv var (dodo|paymob, defaultdodo). Both adapters are registered; a factory bindsPAYMENT_GATEWAY_TOKENto the selected one, so the outbound flow (checkout / MIT charges / cancel) follows the toggle with no code change. Both webhook controllers (/webhooks/dodo,/webhooks/paymob) stay mounted and each verifies with its own concrete gateway, so in-flight subscriptions on the other gateway keep working during a migration.Default gateway: Dodo Payments (
gateways/dodo/, webhook atPOST /webhooks/dodo). Dodo is a merchant-of-record: it handles global tax/compliance, uses persistent customers, products (created on-the-fly per subscription with the recurring price + monthly/yearly cadence), and hosted Checkout Sessions. The base subscription is pure recurring — Dodo auto-debits every cycle and delays the first charge bytrial_period_daysfor a free trial. Webhooks are verified with Standard Webhooks (HMAC-SHA256;webhook-id/webhook-timestamp/webhook-signatureheaders,DODO_WEBHOOK_KEY).subscription.activemaps to the app'scard_savedsignal (asub_<id>marker fillsgateway_card_id);subscription.on_hold→charge_failed(dunning);subscription.cancelled/.expired→subscription_canceled. Config:DODO_BASE_URL(test vs live),DODO_API_KEY,DODO_WEBHOOK_KEY,DODO_CURRENCY(USD, no FX). The Paymob sections below describe that adapter (gateways/paymob/), active whenPAYMENT_GATEWAY=paymob.Mid-cycle money differs by gateway model — the interface flag
supportsSavedCardMitChargepicks the path:
- Paymob (
true) — has a reusable card token → seat proration, plan-change upgrades, and one-time add-on packs are silent saved-card MIT charges (chargeSavedCard).- Dodo (
false) — no reusable token → plan changes go throughchangeSubscriptionPlan(Dodo's nativechange-plan:prorated_immediatelycharges the saved method for the difference on an upgrade,do_not_billat next cycle on a downgrade), and one-time add-on packs are sold via a hosted one-timecreateInitialChargecheckout that returns aredirect_url. Seat changes are deferred to renewal on both. Thekind: plan_change/addon_one_timecharge webhooks apply the plan / credit the pack on capture, the same as the token path. Callers (BillingProrationService,BillingAddOnService) branch on the flag and stay gateway-agnostic.
The model
A workspace has exactly one subscription (subscriptions, one-to-one with
UserWorkspace). The subscription tracks status, period, trial/grace clocks, the
Paymob customer/card/agreement ids, the seat snapshot, and any enterprise overrides
(custom_price, custom_limits). Plans are the seeded pricing catalog; add-ons
extend capacity. See Overview for the lifecycle and
the plan/limit tables.
Two flows, two endpoints
Changing the plan and managing the card are separate concerns:
Change plan POST /user/billing/change-plan ── never opens Paymob (BillingCheckoutService.changePlan)
Set up sub POST /user/billing/checkout ── opens the hosted checkout (BillingCheckoutService.createCheckout)changePlan applies the plan (free trial swap, or an MIT proration charge to the
card on file when active); createCheckout creates the native subscription via a
3DS checkout and returns a Paymob hosted-page redirect_url.
Payment flow (card save → native subscription → MIT top-ups)
Recurring billing runs on Paymob native Subscriptions: once a subscription exists, Paymob auto-debits the saved card every cycle — the app schedules no renewal charges. Mid-cycle adjustments (proration, add-on packs) are separate one-time MIT charges against the saved token.
Two integration IDs: 3DS (first/checkout transaction) + MOTO (recurring auto-deductions & saved-token MIT top-ups).
Add card ONE hosted 3DS checkout creates the subscription + saves the card
Trial subscription_start_date = trial end → first deduction delayed (native free trial)
Recurring native subscription auto-debits each cycle via MOTO (webhook advances the period)
Plan upgrade one-time prorated MIT charge (unlocks the tier now) + update the plan amount in place
Seat change update the plan amount in place (PUT) — no mid-cycle charge, no re-checkoutEvery app-initiated charge carries a metadata.kind (ChargeKind:
initial · renewal · seat_proration · addon_one_time · plan_change),
encoded in special_reference and echoed back on the webhook so the app routes
the outcome correctly. Recurring auto-debits correlate by the callback's
subscription_id instead.
Request → outcome
The real state change lands on the webhook, not on the HTTP response — which
is why the frontend polls /usage after anything that charges:
UI ──REST──▶ BillingCheckoutService ──▶ PaymobGatewayService ──▶ Paymob (hosted page / charge)
│
Paymob ──webhook──▶ POST /webhooks/paymob ──▶ SubscriptionService.processWebhookEvent
──▶ apply plan / activate / credit pack / → past_due
──▶ enqueue billing-enforcement job (suspend / unsuspend)Usage & limits
Metering is a self-contained UsageModule (MAIN db) that feature modules
(messaging, workflow, AI, records) import directly — without pulling in the whole
BillingModule:
| Piece | Job |
|---|---|
UsageLimitResolverService | Effective limit = plan → per-field custom_limits (enterprise) → + recurring add-on grants. Cached 5 min. |
UsageCounterService | Atomic per-workspace/metric/month counters (workspace_usage_counters); draws overflow from one-time pack balances (workspace_consumables). |
WorkspaceIdentityService | Resolves the workspace/tenant identity behind a metered call. |
Hitting a limit throws PlanLimitExceededException → HTTP 402
PLAN_LIMIT_EXCEEDED. Metrics are workflow_executions, ai_credits,
storage_bytes; WhatsApp sessions are counted live at create time, not in the
counter table.
Enforcement pipeline
Failed payment → read-only → suspended → deletion is driven by schedulers + a Bull queue, not inline:
Paymob charge_failed ──webhook──▶ past_due + read_only_since = now (WorkspaceReadOnlyGuard blocks writes → 402)
GraceExpiryScheduler past_due (3d) ─▶ suspended
(no renewal scheduler — Paymob auto-debits the native subscription)
billing-enforcement Q suspend / unsuspend jobs
charge_captured clears read_only_since, cancels pending system deletionDependency direction
Controllers → BillingCheckoutService / BillingAddOnService → SubscriptionService
→ IPaymentGateway (PaymobGatewayService) → Paymob API
→ UsageOverviewService / UsageModule → MAIN db
Schedulers → billing-enforcement queue → SubscriptionEnforcementProcessor → BillingEnforcementService
Paymob webhook → SubscriptionService.processWebhookEvent → DB + enforcement queueDesign principles
- Gateway abstraction — swap gateways by implementing
IPaymentGatewayand replacing the three Paymob providers +PaymobWebhookController; the subscription services never change (seebilling.module.ts). - Webhook is the source of truth — the HTTP response and the Paymob redirect are
never proof of success; poll
/usage. - Idempotent everywhere — per-period charge idempotency keys, and a unique
(gateway, gateway_event_id)onpayment_eventsso a retried webhook is a no-op. - Usage is decoupled —
UsageModuleis importable stand-alone; realtime rides the existing/data/eventssocket via an in-process event (see Events).
Next
- Services, tables, guards → Backend Integration
- Endpoints → REST API
- Paymob webhook → Webhooks