Events
| Field | Detail |
|---|---|
| Source files | packages/core/src/common/events/payment-events.ts (51 lines), user-onboarding-events.ts (61 lines), websocket-events.ts (95 lines) |
| Import | import { <ClassName> } from '@nx/core'; |
| Transport | Redis pub/sub (events), WebSocket (rooms and topics) |
| Total channel classes | 2 event channel classes + 2 WebSocket utility classes |
Overview
BANA uses Redis pub/sub for cross-service event communication. All event channels are defined in @nx/core so that both publishers and consumers reference the same constants. Additionally, two utility classes provide standardized naming for WebSocket rooms and topics used by the Signal service.
All event channel names follow a consistent naming convention:
<domain>.<entity>.<action>Examples: payment.order.success, user.seller.registered, commerce.initialized
1. Event Channel Summary
| Channel | Value | Publisher | Consumer(s) |
|---|---|---|---|
PaymentEventChannels.PAYMENT_SUCCESS | payment.order.success | Sale Service | Finance Service, Inventory Service |
UserOnboardingEventChannels.SELLER_REGISTERED | user.seller.registered | Identity Service | Commerce Service |
UserOnboardingEventChannels.COMMERCE_INITIALIZED | commerce.initialized | Commerce Service | Finance Service, Inventory Service |
2. PaymentEventChannels
Source: packages/core/src/common/events/payment-events.ts
Channel
| Name | Value | Description |
|---|---|---|
PAYMENT_SUCCESS | 'payment.order.success' | Payment for a sale order completed successfully (PARTIAL or COMPLETED status) |
- Published by: Sale Service (via
SalePaymentEventHandlerService) - Consumed by: Finance Service (record income transaction), Inventory Service (deduct stock)
Payload: TPaymentSuccessEvent
export type TPaymentSuccessEvent = {
saleOrderId: string;
saleOrderNumber: string;
saleOrderStatus: string;
merchantId: string;
saleChannelId: string;
payment: {
total: number;
paid: number;
currency: string;
isFullyPaid: boolean;
paidAt: string; // ISO timestamp
financeWalletId?: string; // Optional: target wallet for finance recording
};
items: Array<{
id: string;
itemType: string;
itemId: string;
quantity: number;
mode: string;
}>;
createdBy: string;
modifiedBy: string;
};| Field | Type | Description |
|---|---|---|
saleOrderId | string | Snowflake ID of the sale order |
saleOrderNumber | string | Human-readable order number |
saleOrderStatus | string | Current order status after payment |
merchantId | string | Merchant ID (needed for finance and inventory operations) |
saleChannelId | string | Sale channel where the order was placed |
payment.total | number | Total order amount |
payment.paid | number | Amount paid in this payment |
payment.currency | string | Payment currency code |
payment.isFullyPaid | boolean | Whether the order is fully paid |
payment.paidAt | string | ISO timestamp of payment completion |
payment.financeWalletId | string? | Target finance wallet for income recording |
items | Array | Order items with quantity and type information |
createdBy | string | User who created the order |
modifiedBy | string | User who last modified the order |
Payment Flow
Usage
import { PaymentEventChannels } from '@nx/core';
import type { TPaymentSuccessEvent } from '@nx/core';
// Publisher (Sale Service)
await eventBus.publish(PaymentEventChannels.PAYMENT_SUCCESS, {
saleOrderId: order.id,
saleOrderNumber: order.number,
saleOrderStatus: order.status,
merchantId: order.merchantId,
saleChannelId: order.saleChannelId,
payment: {
total: order.total,
paid: attemptAmount,
currency: 'VND',
isFullyPaid: true,
paidAt: new Date().toISOString(),
},
items: order.items.map(item => ({
id: item.id,
itemType: item.itemType,
itemId: item.itemId,
quantity: item.quantity,
mode: item.mode,
})),
createdBy: order.createdBy,
modifiedBy: order.modifiedBy,
} satisfies TPaymentSuccessEvent);
// Consumer (Finance Service)
eventBus.subscribe(PaymentEventChannels.PAYMENT_SUCCESS, async (data: TPaymentSuccessEvent) => {
await financeWorkerService.handlePaymentSuccess(data);
});3. UserOnboardingEventChannels
Source: packages/core/src/common/events/user-onboarding-events.ts
Channels
| Name | Value | Description |
|---|---|---|
SELLER_REGISTERED | 'user.seller.registered' | A seller completes registration |
COMMERCE_INITIALIZED | 'commerce.initialized' | Commerce entities (Organizer, Merchant, SaleChannel) are created |
Payload: TSellerRegisteredEvent
export type TSellerRegisteredEvent = {
userId: string;
organizer: {
name: { en: string; vi: string };
slug: string;
};
merchant: {
name: { en: string; vi: string };
slug: string;
currency: string;
type: string;
};
saleChannel: {
name: { en: string; vi: string };
slug: string;
};
};| Field | Type | Description |
|---|---|---|
userId | string | ID of the newly registered user |
organizer.name | { en, vi } | Organizer display name (bilingual) |
organizer.slug | string | URL-friendly organizer identifier |
merchant.name | { en, vi } | Merchant display name (bilingual) |
merchant.slug | string | URL-friendly merchant identifier |
merchant.currency | string | Default currency code (e.g., VND) |
merchant.type | string | Merchant type (see MerchantTypes constants) |
saleChannel.name | { en, vi } | Sale channel display name (bilingual) |
saleChannel.slug | string | URL-friendly sale channel identifier |
- Published by: Identity Service (after user sign-up)
- Consumed by: Commerce Service (creates Organizer, Merchant, SaleChannel entities)
Payload: TCommerceInitializedEvent
export type TCommerceInitializedEvent = {
userId: string;
organizerId: string;
merchantId: string;
saleChannelId: string;
};| Field | Type | Description |
|---|---|---|
userId | string | ID of the user who triggered onboarding |
organizerId | string | ID of the newly created Organizer |
merchantId | string | ID of the newly created Merchant |
saleChannelId | string | ID of the newly created SaleChannel |
- Published by: Commerce Service (after creating entities)
- Consumed by: Finance Service (creates default Cash wallet), Inventory Service (initializes stock tracking)
Onboarding Flow
Usage
import { UserOnboardingEventChannels } from '@nx/core';
import type { TSellerRegisteredEvent, TCommerceInitializedEvent } from '@nx/core';
// Publisher (Identity Service)
await eventBus.publish(UserOnboardingEventChannels.SELLER_REGISTERED, {
userId: newUser.id,
organizer: { name: { en: 'My Store', vi: 'Cua Hang' }, slug: 'my-store' },
merchant: { name: { en: 'My Store', vi: 'Cua Hang' }, slug: 'my-store', currency: 'VND', type: '000_DEFAULT' },
saleChannel: { name: { en: 'Default', vi: 'Mac dinh' }, slug: 'default' },
} satisfies TSellerRegisteredEvent);
// Consumer (Commerce Service)
eventBus.subscribe(
UserOnboardingEventChannels.SELLER_REGISTERED,
async (data: TSellerRegisteredEvent) => {
await commerceOnboardingService.initializeForSeller(data);
},
);
// Publisher (Commerce Service)
await eventBus.publish(UserOnboardingEventChannels.COMMERCE_INITIALIZED, {
userId,
organizerId: organizer.id,
merchantId: merchant.id,
saleChannelId: saleChannel.id,
} satisfies TCommerceInitializedEvent);
// Consumer (Finance Service)
eventBus.subscribe(
UserOnboardingEventChannels.COMMERCE_INITIALIZED,
async (data: TCommerceInitializedEvent) => {
await financeWorkerService.handleCommerceInitialized(data);
},
);4. WebSocketRooms
Source: packages/core/src/common/events/websocket-events.ts
Utility class for building standardized WebSocket room identifiers used by the Signal service.
Constants
| Name | Value | Description |
|---|---|---|
ROOM_WR_PREFIX | 'wr' | Prefix for all room identifiers |
ROOM_OBSERVATION_PREFIX | 'observation' | Default scope prefix for observation rooms |
Room Format
wr:{prefix}/{path1}/{path2}/...The default prefix is observation. A custom prefix can be provided for non-observation rooms.
Method: build()
static build(opts: { prefix?: string; paths: Array<string | number> }): string| Parameter | Type | Default | Description |
|---|---|---|---|
prefix | string? | 'observation' | Room scope prefix |
paths | Array<string | number> | -- | Ordered path segments appended after the prefix |
Returns: Formatted room string: wr:{prefix}/{paths joined by /}
Throws: HttpError (400) if no valid path segments are provided.
Falsy values in the paths array are automatically filtered out.
Examples
import { WebSocketRooms } from '@nx/core';
// Default prefix (observation)
WebSocketRooms.build({ paths: ['merchants', 'abc-123'] });
// => 'wr:observation/merchants/abc-123'
WebSocketRooms.build({ paths: ['merchants', 'abc-123', 'sale-orders'] });
// => 'wr:observation/merchants/abc-123/sale-orders'
// Custom prefix
WebSocketRooms.build({ prefix: 'control', paths: ['sessions', 'sess-456'] });
// => 'wr:control/sessions/sess-456'
// Falsy values are filtered
WebSocketRooms.build({ paths: ['merchants', '', 'orders'] });
// => 'wr:observation/merchants/orders'5. WebSocketTopics
Source: packages/core/src/common/events/websocket-events.ts
Utility class for building standardized WebSocket topic identifiers. Topics categorize WebSocket messages so clients can subscribe to specific event streams.
Constants
| Name | Value | Description |
|---|---|---|
TOPIC_WS_PREFIX | 'ws' | Prefix for all topic identifiers |
Topic Format
ws:{path1}.{path2}.{path3}Method: build()
static build(opts: { paths: Array<string | number> }): string| Parameter | Type | Description |
|---|---|---|
paths | Array<string | number> | Ordered path segments joined by . after the ws: prefix |
Returns: Formatted topic string: ws:{paths joined by .}
Throws: HttpError (400) if no valid path segments are provided.
Falsy values in the paths array are automatically filtered out.
Examples
import { WebSocketTopics } from '@nx/core';
WebSocketTopics.build({ paths: ['observation', 'sale', 'sale-order'] });
// => 'ws:observation.sale.sale-order'
WebSocketTopics.build({ paths: ['observation', 'payment', 'transaction'] });
// => 'ws:observation.payment.transaction'
WebSocketTopics.build({ paths: ['notification', 'merchant', 'abc-123'] });
// => 'ws:notification.merchant.abc-123'6. Combined Architecture
Event Flow Across Services
Event vs Queue
Events (Redis pub/sub) and queues (BullMQ) serve different purposes in BANA:
| Aspect | Events (pub/sub) | Queues (BullMQ) |
|---|---|---|
| Delivery | Fire-and-forget broadcast | Guaranteed delivery with retries |
| Consumers | Multiple subscribers | Single consumer per partition |
| Persistence | No persistence | Persisted in Redis |
| Use case | Notifications, real-time updates | Critical job processing |
| Defined in | common/events/ | common/queues/ |
TIP
Events and queues often work together. An event handler may enqueue a job for reliable processing. For example, the PAYMENT_SUCCESS event is received by the Sale Service, which then enqueues jobs via SaleQueueDefinitions for both Finance and Inventory processing.
Best Practices
1. Always Use Channel Constants
// Good -- type-safe and refactorable
eventBus.subscribe(PaymentEventChannels.PAYMENT_SUCCESS, handler);
// Bad -- magic string
eventBus.subscribe('payment.order.success', handler);2. Type Event Payloads
// Good -- typed payload
eventBus.subscribe(
PaymentEventChannels.PAYMENT_SUCCESS,
async (data: TPaymentSuccessEvent) => {
// TypeScript knows the shape of data
const { merchantId, payment } = data;
},
);
// Bad -- untyped
eventBus.subscribe(PaymentEventChannels.PAYMENT_SUCCESS, async (data: any) => { });3. Use WebSocket Helpers for Consistent Naming
// Good -- standardized room/topic naming
const room = WebSocketRooms.build({ paths: ['merchants', merchantId, 'sale-orders'] });
const topic = WebSocketTopics.build({ paths: ['observation', 'sale', 'sale-order'] });
// Bad -- manual string construction
const room = `wr:observation/merchants/${merchantId}/sale-orders`;
const topic = `ws:observation.sale.sale-order`;