Events
Billing — Events
Billing has no dedicated WebSocket namespace. Metered usage rides the
existing /data/events socket; everything else is in-process domain events and a
Bull enforcement queue.
Realtime — usage:updated
When AI credits or workflow executions are billed, UsageCounterService emits an
in-process usage.recorded event. UsageRealtimeListener (in the records module)
bridges it to a usage:updated push on the /data/events namespace — so the
header usage bar updates live, no polling, no refetch.
Payload (server → client):
'usage:updated': {
metric: 'ai_credits' | 'workflow_executions',
charged: number, // amount billed in this event
remaining: number | null, // balance AFTER this charge; null = unlimited
}remainingis computed with the same formula asGET /usage(max(0, limit - used) + pack_balance), so you can render it directly — no follow-up fetch.- Room routing: hyper →
workspace:{id}(never the sharedtenant:room, which would leak across workspaces); dedicated →tenant:{db}. Every teammate in the workspace receives it — credits are a shared pool. - Best-effort: the emit is fully guarded, so a realtime hiccup never affects the billed write.
Scope — metered consumption only. Plan/card changes still land via the async
Paymob webhook, so those need /usage polling (see Webhooks).
Domain events (server-side, EventEmitter2)
| Event | Emitter → Listener | Effect |
|---|---|---|
billing.seat.changed | workspace membership → SeatChangeListener | BillingProrationService.applySeatChange — deferred: updates the seat snapshot, no mid-cycle charge; the new count bills at the next renewal. Payload: { userWorkspaceId, activeSeatCount }. Errors are swallowed. |
usage.recorded | UsageCounterService → UsageRealtimeListener | Bridged to the usage:updated socket push above. |
billing.subscription.lifecycle | SubscriptionLifecyclePublisher → BillingNotificationListener (notification module) | Notifies every workspace super-admin that the subscription was canceled or renewed — in-app record + FCM push. Errors are swallowed. |
billing.subscription.lifecycle
Emitted for exactly two transitions — nothing else on the payment path notifies:
kind | Emitted from |
|---|---|
canceled | BillingCheckoutService.cancelSubscription (user hit POST /user/billing/cancel) and SubscriptionService.applyEvent on a subscription_canceled webhook (external cancel, dunning exhausted, term expired) |
renewed | SubscriptionService.applyEvent on a charge_captured that advanced an existing period. The first capture records SUBSCRIPTION_ACTIVATED and deliberately does not notify. |
interface BillingSubscriptionLifecycleEvent {
kind: 'canceled' | 'renewed';
userWorkspaceId: number; // MAIN-db user_workspaces.id
workspaceName: string;
planName: string;
tenantDatabase: string; // routing — see below
workspaceId: number | null;
subdomain: string;
periodEnd: Date | null; // access end (canceled) / next charge (renewed)
}Two constraints shape this payload:
- Published after the commit.
processWebhookEventreturns the recordedPaymentEventTypeout of its transaction and only then publishes, so a rolled-back webhook leaves no notification behind. - Routing travels on the payload. Billing state lives in the MAIN db, but
admin/admin_devices/notificationslive tenant-side, and the webhook that triggers this carries no tenant context. The publisher resolvestenant.database+ the workspace pin up front; the listener (async: true, so detached from any AsyncLocalStorage) re-enters aTenantScopefrom them.workspaceIdis the workspace id on a shared hyper tenant andnullon a dedicated tenant, where the database boundary is the isolation.
Recipients are every super_admin + is_active admin of the workspace. Because
admin is RLS-carved-out on the hyper db, that query filters workspace_id
manually rather than relying on the pin.
Enforcement queue (Bull)
State changes that must not run inline go through the billing-enforcement queue
→ SubscriptionEnforcementProcessor:
| Job | Handler | When |
|---|---|---|
suspend-workspace | enterReadOnly | Webhook reports a subscription is now suspended |
unsuspend-workspace | exitReadOnly | charge_captured reactivated the subscription |
Jobs retry 3× with exponential backoff.