Corteksa
GuidesBilling

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
}
  • remaining is computed with the same formula as GET /usage (max(0, limit - used) + pack_balance), so you can render it directly — no follow-up fetch.
  • Room routing: hyper → workspace:{id} (never the shared tenant: 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)

EventEmitter → ListenerEffect
billing.seat.changedworkspace membership → SeatChangeListenerBillingProrationService.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.recordedUsageCounterServiceUsageRealtimeListenerBridged to the usage:updated socket push above.
billing.subscription.lifecycleSubscriptionLifecyclePublisherBillingNotificationListener (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:

kindEmitted from
canceledBillingCheckoutService.cancelSubscription (user hit POST /user/billing/cancel) and SubscriptionService.applyEvent on a subscription_canceled webhook (external cancel, dunning exhausted, term expired)
renewedSubscriptionService.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. processWebhookEvent returns the recorded PaymentEventType out 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 / notifications live tenant-side, and the webhook that triggers this carries no tenant context. The publisher resolves tenant.database + the workspace pin up front; the listener (async: true, so detached from any AsyncLocalStorage) re-enters a TenantScope from them. workspaceId is the workspace id on a shared hyper tenant and null on 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:

JobHandlerWhen
suspend-workspaceenterReadOnlyWebhook reports a subscription is now suspended
unsuspend-workspaceexitReadOnlycharge_captured reactivated the subscription

Jobs retry 3× with exponential backoff.

Next

  • Provider webhook that triggers these → Webhooks
  • Working client code → Examples

On this page