Corteksa
GuidesBilling

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_GATEWAY env var (dodo | paymob, default dodo). Both adapters are registered; a factory binds PAYMENT_GATEWAY_TOKEN to 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 at POST /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 by trial_period_days for a free trial. Webhooks are verified with Standard Webhooks (HMAC-SHA256; webhook-id/webhook-timestamp/webhook-signature headers, DODO_WEBHOOK_KEY). subscription.active maps to the app's card_saved signal (a sub_<id> marker fills gateway_card_id); subscription.on_holdcharge_failed (dunning); subscription.cancelled / .expiredsubscription_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 when PAYMENT_GATEWAY=paymob.

Mid-cycle money differs by gateway model — the interface flag supportsSavedCardMitCharge picks 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 through changeSubscriptionPlan (Dodo's native change-plan: prorated_immediately charges the saved method for the difference on an upgrade, do_not_bill at next cycle on a downgrade), and one-time add-on packs are sold via a hosted one-time createInitialCharge checkout that returns a redirect_url. Seat changes are deferred to renewal on both. The kind: plan_change / addon_one_time charge 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-checkout

Every 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:

PieceJob
UsageLimitResolverServiceEffective limit = plan → per-field custom_limits (enterprise) → + recurring add-on grants. Cached 5 min.
UsageCounterServiceAtomic per-workspace/metric/month counters (workspace_usage_counters); draws overflow from one-time pack balances (workspace_consumables).
WorkspaceIdentityServiceResolves the workspace/tenant identity behind a metered call.

Hitting a limit throws PlanLimitExceededExceptionHTTP 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 deletion

Dependency 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 queue

Design principles

  • Gateway abstraction — swap gateways by implementing IPaymentGateway and replacing the three Paymob providers + PaymobWebhookController; the subscription services never change (see billing.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) on payment_events so a retried webhook is a no-op.
  • Usage is decoupledUsageModule is importable stand-alone; realtime rides the existing /data/events socket via an in-process event (see Events).

Next

On this page