Backend Integration
Billing — Backend Integration
For backend developers extending or maintaining Billing. All billing state
lives in the MAIN database, keyed by user_workspace_id.
Services
| Service | Job |
|---|---|
BillingCheckoutService | changePlan (plan change), createCheckout (opens the hosted checkout / sets up the subscription), cancelSubscription, getPaymentStatus. Controllers call this. |
SubscriptionService | Subscription lifecycle: processWebhookEvent, trial/grace transitions, getPaymentHistory. |
BillingProrationService | applySeatChange + plan-change proration (difference-based). |
BillingPricingService | Resolves plan price / seat totals for a charge. |
BillingEnforcementService | enterReadOnly / exitReadOnly; enrolls & cancels billing-initiated deletion. |
BillingAddOnService | listCatalog, purchaseAddOn, cancelRecurringAddOn. |
BillingInvoiceService | generateInvoicePdf (branded PDF via Puppeteer). |
UsageOverviewService | Builds the GET /usage payload. |
The payment gateway is injected by token (PAYMENT_GATEWAY_TOKEN →
PaymobGatewayService, aliased to IPaymentGateway). Swap gateways by implementing
that interface and replacing the three Paymob providers + PaymobWebhookController —
the subscription services need no changes.
UsageModule (importable stand-alone)
billing/usage/ is a self-contained metering module (MAIN db) that other feature
modules import without the full BillingModule. Exports:
UsageLimitResolverService, UsageCounterService, WorkspaceIdentityService.
- Gate before / record after.
ensureAiAllowance(throws 402) thenrecordAiCredits(post-hoc real cost);hasWorkflowAllowance(peek) thenrecordWorkflowExecution(never throws);checkStorage/addStorageBytes. - Counters are keyed by month (
period_key=YYYY-MM); storage uses the cumulative key*. Overflow beyond the plan limit draws from one-time pack balances (workspace_consumables).
Controllers & guards
| Controller | Path | Guard | Notes |
|---|---|---|---|
BillingController | /user/billing | UserJwtAuthGuard | @AllowWhenReadOnly() — stays writable while read-only so the customer can pay. |
BillingAddOnController | /user/billing/addons | UserJwtAuthGuard | @AllowWhenReadOnly(). |
PaymobWebhookController | /webhooks/paymob | PaymobWebhookGuard | Public; @SkipUserJwt/@SkipAdminJwt/@SkipPermissions/@SkipTransform. Signature-authenticated. |
AiPricingAdminController | /admin/ai-pricing | AdminAuthGuard + SuperAdminGuard | Platform pricing config. |
AiCostAnalyticsController | /admin/ai-analytics | AdminAuthGuard + SuperAdminGuard | Read-only cost dashboard. |
User billing endpoints authenticate with the user JWT (not the admin JWT) and
require an active workspace on the user (user.workspaceId, else
400 "No workspace selected"). They carry no PermissionGuard/@RouteName
— the only gate is the read-only guard below. The plan catalog
(GET /admin/pricing/plans) lives in the separate pricing module.
WorkspaceReadOnlyGuard (global)
Registered as an APP_GUARD in billing.module.ts. On every write
(POST/PUT/PATCH/DELETE) it checks the workspace's subscription.read_only_since;
if set it throws 402 PAYMENT_REQUIRED. Reads (GET/HEAD/OPTIONS) always pass.
@AllowWhenReadOnly() (billing/payment + workspace-deletion) bypasses it. It
decodes its own JWT, so guard ordering is irrelevant.
Database tables (MAIN db)
| Table | Entity | Holds |
|---|---|---|
subscriptions | Subscription | One per workspace: status, trial/period/grace clocks, read_only_since, Paymob gateway_* ids, seat_count, custom_price/custom_limits. |
payment_events | PaymentEvent | Invoice + audit ledger. Unique (gateway, gateway_event_id) ⇒ webhook idempotency. |
addons | AddOn | Global seeded add-on catalog. |
workspace_addons | WorkspaceAddOn | Active recurring add-ons per workspace (unique per workspace+addon). |
workspace_consumables | WorkspaceConsumable | One-time pack balances (workflow / AI). |
workspace_usage_counters | WorkspaceUsageCounter | Per-workspace/metric/month counters. |
ai_model_pricing · ai_credit_setting · ai_usage_event | — | AI-run cost catalog + immutable ledger (super-admin). |
Events & queues
- Domain events (EventEmitter2):
billing.seat.changed→SeatChangeListener→applySeatChange;usage.recorded(emitted byUsageCounterService) → realtimeusage:updated. See Events. - Bull queue
billing-enforcement→SubscriptionEnforcementProcessor:suspend-workspace,unsuspend-workspace. (No renewal job — Paymob auto-debits the native subscription.) - Schedulers (
@Cron, env-overridable):TrialExpiryScheduler(card-less trials → past_due),GraceExpiryScheduler. There is no renewal scheduler — renewals are auto-debited by Paymob. The full enforcement ladder (read-only-delete, workspace-purge) is in Overview §11.
Multi-tenant safety
Billing tables are MAIN-db and keyed by user_workspace_id, so they are not
subject to hyper-tenant RLS pinning — but any metered write you add through
UsageModule runs on the caller's identity via WorkspaceIdentityService. See
Data isolation before touching cross-tenant paths.