Sale Order
1. Overview
The SaleOrder is the central entity of the Sale Service. It serves a dual purpose — shopping cart (DRAFT) and committed order (PROCESSING → COMPLETED) — eliminating the need for separate Cart and Order entities.
Source: Entity schemas defined in @nx/core (packages/core/src/models/schemas/sale/).
2. Entity Relationship Diagram
3. SaleOrder Fields
Identification
| Field | Type | Description |
|---|---|---|
id | text | Snowflake ID generated by IdGenerator.getInstance() |
orderNumber | text | Format: YYYYMMDDHHmmss-<snowflakeId> (e.g., 20250128143025-8a7b6c5d) |
name | text | Display name — defaults to orderNumber if not provided |
slug | text | URL-friendly identifier: SaleOrder-<orderNumber> |
Order number generation (SaleOrderService.createDraftOrder):
const orderNumber = [dayjs().format('YYYYMMDDHHmmss'), this.idGenerator.nextId()].join('-');
const slug = [SaleOrder.name, orderNumber].join('-');Relationships
| Field | Type | Description |
|---|---|---|
saleChannelId | text FK | Sale channel this order belongs to |
merchantId | text FK | Derived from the sale channel's merchantId at creation |
createdBy | text FK | User who created the order |
modifiedBy | text FK | User who last modified the order |
Financial
| Field | Type | Description |
|---|---|---|
currency | text | ISO 4217 code (default: VND). Fixed at creation |
exchangeRate | numeric | Exchange rate if currency conversion needed |
subtotal | numeric | Sum of all item totals (maintained by updateSummaryFromItems) |
tax | numeric | Sum of all item taxes |
discount | numeric | Sum of all item discounts |
total | numeric | subtotal - discount + tax (minimum 0) |
Status & Tracking
| Field | Type | Description |
|---|---|---|
status | text | Current status code (see lifecycle below) |
counter | jsonb | Payment progress: { total, paid, paidItemIds } |
validity | jsonb | Optional date range: { from, to } |
metadata | jsonb | Checkout context: { merchantId, note?, finance } (set at checkout) |
checkSplitAt | timestamp | Set when checks created (split), cleared on rollback. Guards against double-split |
orderSplitAt | timestamp | Set when order is split into multiple orders. Informational -- never cleared |
cancellationReason | text | Reason string when cancelled |
Metadata
The metadata JSONB field stores checkout context for downstream services. It is initialized at draft creation with default finance settings and updated at checkout:
| Property | Type | Set At | Description |
|---|---|---|---|
merchantId | string | Draft creation | Merchant reference (from sale channel) |
finance.use | boolean | Checkout | Whether to record income in Finance module |
finance.walletId | string? | Checkout | Finance wallet ID (when use: true) |
finance.categoryId | string? | Checkout | Finance category ID (when use: true) |
note | string? | Checkout | Optional checkout note |
Timestamps
| Field | Set When |
|---|---|
draftAt | Order created (DRAFT) |
processingAt | Checkout (PROCESSING) |
partialAt | First partial payment (PARTIAL) |
completedAt | Fully paid (COMPLETED) |
cancelledAt | Cancelled (CANCELLED) |
createdAt | Record created |
modifiedAt | Record last modified |
deletedAt | Soft delete (null if active) |
4. Status Lifecycle
Status Codes
| Status | Code | Phase | Description |
|---|---|---|---|
| DRAFT | 001_DRAFT | Cart | Shopping cart — items can be added/removed/modified |
| PROCESSING | 203_PROCESSING | Payment | Checked out, awaiting payment confirmation |
| PARTIAL | 300_PARTIAL | Payment | Some payment received, awaiting remainder |
| COMPLETED | 303_COMPLETED | Terminal | Fully paid, order fulfilled |
| CANCELLED | 505_CANCELLED | Terminal | Order terminated |
State Machine
Transition Rules
| From | To | Trigger | Service Method | Validation |
|---|---|---|---|---|
| (new) | DRAFT | Create order | SaleOrderService.createDraftOrder() | Valid saleChannelId required |
| DRAFT | PROCESSING | Checkout | CheckoutService.checkout() | Non-empty cart, non-negative prices, quantity ≥ 1 |
| DRAFT | CANCELLED | Cancel | SaleOrderService.cancelOrder() | Not terminal status |
| PROCESSING | DRAFT | Revert | CheckoutService.revertCheckout() | SaleOrderStatuses.canRevertToCart() returns true |
| PROCESSING | PARTIAL | Payment success (partial) | SaleOrderPaymentWebhookService._handleOrderPaymentSuccess | paid < total |
| PROCESSING | COMPLETED | Payment success (full) | SaleOrderPaymentWebhookService._handleOrderPaymentSuccess | paid >= total |
| PROCESSING | CANCELLED | Payment failed/expired/cancelled | SaleOrderPaymentWebhookService._handleOrderPayment{Failed,Expired,Cancelled} | Status must be PROCESSING |
| PROCESSING | COMPLETED | Last check COMPLETED | SaleCheckPaymentWebhookService._checkOrderCompletionViaChecks | All checks on order in SaleCheckStatuses.COMPLETED |
| PARTIAL | COMPLETED | Payment success (full) | SaleOrderPaymentWebhookService._handleOrderPaymentSuccess | paid >= total |
| PARTIAL | CANCELLED | Cancel | SaleOrderService.cancelOrder | Not terminal status |
5. SaleOrderItem Fields
Identification & Reference
| Field | Type | Description |
|---|---|---|
id | text | Snowflake ID |
saleOrderId | text FK | Parent order |
mode | text | 000_PRODUCT or 100_CUSTOM (from SaleOrderItemModes) |
itemType | text | Reference type (e.g., ProductVariant, CustomProductVariant) |
itemId | text | External reference (product variant ID, or CPV_<uuid> for custom) |
leadItemId | text | Original item ID if this is a linked item |
Pricing
| Field | Type | Description |
|---|---|---|
currency | text | Item currency |
basePrice | numeric | Original price before discounts |
unitPrice | numeric | Final price per unit |
discount | numeric | Discount amount |
quantity | numeric | Number of units (stored as string in DB, parsed as number) |
tax | numeric | Calculated tax amount |
total | numeric | Calculated by updateSummaryFromItems() |
Pricing Source
| Field | Type | Description |
|---|---|---|
transferHistory | jsonb | Array of { sourceOrderId, targetOrderId, transferredAt } entries tracking merge chain. null = native item |
fareId | text | Reference to pricing fare |
fareProvider | text | Pricing provider identifier |
priceMetadata | jsonb | Contains the full fareSource object |
metadata | jsonb | Product snapshot (PRODUCT mode: auto-populated server-side) or custom item details (CUSTOM mode: client-provided) |
Product Metadata Snapshot
For PRODUCT mode items, the metadata field is automatically populated server-side at add-to-cart time by ProductVariantSnapshotService. This captures a point-in-time snapshot of the ProductVariant data, ensuring historical accuracy even if the product is later modified.
The FK reference (itemId/itemType) is retained for referential integrity, but metadata is the source of truth for what was sold.
| Snapshot Field | Source | Description |
|---|---|---|
name | ProductInfo.name | i18n product name { default, en?, vi? } |
description | ProductInfo.description | Product description (default language) |
sku | ProductIdentifier (scheme=SKU) | Stock-keeping unit |
barcode | ProductIdentifier (scheme=BARCODE) | Barcode value |
imageUrl | MetaLink.link | First product image (most recent) |
externalId | ProductVariant.identifier | The PV_YYYYMMDD_xxx identifier |
externalSource | 'ProductVariant' | Literal, marks this as a server-side snapshot |
Note: For CUSTOM mode items,
metadatacontinues to use client-providedproductMetadata. Custom items have no real ProductVariant to snapshot.
6. Item Modes
Product Mode (000_PRODUCT)
Source: SaleOrderService._addProductItem() (lines 295–350)
| Behavior | Implementation |
|---|---|
| Reference | Links to existing ProductVariant by itemId + itemType |
| Type branching | ProductVariantTypes.isCombo(variant.type) → _addComboProductItem (see below). Otherwise → standard single-PV path. No category lookup |
| Metadata snapshot | Server-side snapshot of ProductVariant data (name, SKU, barcode, image) via ProductVariantSnapshotService.buildSnapshot(). Client-provided productMetadata is ignored |
| Pricing | fareSource from FareSourceSchema (includes fareId, unitPrice, basePrice, tax) |
| Duplicate merge | Queries existing item by {saleOrderId, itemId, itemType}. If found, increases quantity, updates price, and refreshes the metadata snapshot. If not, creates new item with snapshot |
| Inventory | Connected to stock tracking via Inventory Service |
| Row locking | Parent order is locked via SELECT ... FOR UPDATE (findWithItemCount with doLock: true) to prevent race conditions when multiple concurrent requests add items |
COMBO sub-mode (_addComboProductItem)
When variant.type is COMBO, cart-add expands the bundler into child rows. Details:
| Aspect | Detail |
|---|---|
| Lead row | One SaleOrderItem with the combo PV; full price; leadItemId = null |
| Children | N SaleOrderItem rows produced by BundleExpansionService.extractComboItems. Each: leadItemId = lead.id, unitPrice = 0, basePrice = 0, tax = 0, metadata.combo = { leadVariantId, bundlerRowIds } |
| Reservation | One applyReservationDelta per leaf, bounded parallel (executePromiseWithLimit, limit=10). Combo lead reserves nothing — a COMBO-typed PV has no InventoryItem (not in STOCKABLE_SET, so CDC never seeds it) |
| Atomicity | All reservations + inserts share the caller transaction. Any leaf STOCK_UNAVAILABLE rolls back the whole combo add |
| Re-add guard | Re-adding the same combo throws COMBO_ALREADY_IN_ORDER. Use the edit endpoint to change quantity |
| Nested combos | extractComboItems recurses through related variants whose own type is COMBO, with depth (MAX=5) + cycle guards. Leaves are aggregated by relatedVariantId |
| Empty bundler | COMBO_HAS_NO_COMPONENTS — catalog misconfiguration surfaces at first sale |
| Edits | Lead-driven only — see order-operations.md. Direct child edit rejected with COMBO_CHILD_EDIT_FORBIDDEN |
ADR: developer/packages/inventory/decisions/0006-combo-explosion-at-cart-add.
// Duplicate detection — if same product exists, merge quantities
const currentItem = await this._saleOrderItemRepository.findOne({
filter: { where: { saleOrderId: saleOrder.id, itemId, itemType } },
options: { transaction: tx },
});
if (currentItem) {
const newQuantity = Number(currentItem.quantity) + quantity;
// Update existing item with merged quantity
}Custom Mode (100_CUSTOM)
Source: SaleOrderService._addCustomItem() (lines 352–378)
| Behavior | Implementation |
|---|---|
| Reference | Auto-generated: CPV_<crypto.randomUUID()> |
| Item type | Always CustomProductVariant |
| Pricing | fareSource from FareSourceManualSchema (manual unitPrice, basePrice, optional tax) |
| No merge | Every addition creates a new line item (no duplicate detection) |
| Inventory | Not tracked |
| Row locking | Same pessimistic lock as PRODUCT mode |
const { data: orderItem } = await this._saleOrderItemRepository.create({
data: {
saleOrderId: saleOrder.id,
mode: SaleOrderItemModes.CUSTOM,
itemId: `CPV_${crypto.randomUUID()}`,
itemType: 'CustomProductVariant',
// ...
},
});7. Tax Calculation
Source: SaleOrderService._calculateTax() (lines 377–406)
Tax is calculated per-item based on fareSource.tax:
| Mode | Formula | Example |
|---|---|---|
AMOUNT | Fixed amount: value | tax = 10000 (10,000 VND flat) |
PERCENTAGE | unitPrice × quantity × (value / 100) | 50000 × 2 × (10 / 100) = 10000 |
| (none) | 0 | No tax configured |
private _calculateTax(opts: {
fareSource: { tax?: { mode: 'AMOUNT' | 'PERCENTAGE'; value: number }; unitPrice: number };
quantity: number;
}) {
if (!fareSource.tax) { return 0; }
switch (mode) {
case 'AMOUNT': return value;
case 'PERCENTAGE': return fareSource.unitPrice * quantity * (value / 100);
}
}8. Request Schemas
Source: src/models/requests/sale.model.ts
CreateDraftOrderRequest
const CreateDraftOrderRequestSchema = z.object({
saleChannelId: z.string().min(1).max(255),
name: z.string().min(1).max(255).optional(),
currency: z.string().min(3).max(3).default('VND').optional(),
validity: z.object({ from: z.string(), to: z.string() }).optional(),
});AddItemRequest (Discriminated Union)
const AddItemRequestSchema = z.discriminatedUnion('mode', [
// PRODUCT mode — requires itemType, itemId, FareSourceSchema
AddItemProductRequestSchema,
// CUSTOM mode — requires FareSourceManualSchema only
AddItemCustomRequestSchema,
]);| Field | Product Mode | Custom Mode |
|---|---|---|
mode | 000_PRODUCT (literal) | 100_CUSTOM (literal) |
quantity | 1–9999 (default: 1) | 1–9999 (default: 1) |
itemType | Required (default: ProductVariant) | Not applicable |
itemId | Required (product variant ID) | Not applicable (auto-generated) |
fareSource | FareSourceSchema (system pricing) | FareSourceManualSchema (manual pricing) |
productMetadata | Ignored (server-side snapshot) | Optional (client-provided) |
CheckoutRequest / CancelOrderRequest
const CheckoutRequestSchema = z.object({
note: z.string().max(1000).optional(),
finance: z.object({ use: z.literal(false) })
.or(z.object({
use: z.literal(true),
walletId: z.string(),
categoryId: z.string(),
})),
});
const CancelOrderRequestSchema = z.object({
reason: z.string().min(1).max(500).optional(),
});The finance field is required and determines whether the Finance module records income when payment succeeds:
| Variant | Purpose |
|---|---|
{ use: false } | No finance recording |
{ use: true, walletId, categoryId } | Record income in specified wallet and category |
9. Payment Counter
The counter JSONB field tracks payment progress:
| Property | Type | Description |
|---|---|---|
total | number | Total amount due |
paid | number | Amount paid so far |
paidItemIds | string[] | List of item IDs that have been paid |
Initial value (set in createDraftOrder):
counter: { total: 0, paid: 0, paidItemIds: [] },
metadata: {
merchantId: saleChannel.merchantId,
finance: { use: false },
}10. Business Constraints
Source: src/common/constants.ts
| Constraint | Value | Enforced By |
|---|---|---|
MAX_QUANTITY_PER_ITEM | 9999 | SaleOrderItemService._validateQuantity() |
MAX_NUMBER_OF_ITEMS_IN_ORDER | 100 | SaleOrderService.addSaleOrderItem() |
| Currency lock | Fixed at creation | CreateDraftOrderRequestSchema |
| Edit lock | DRAFT only | SaleOrderStatuses.canModifyItems() |
| Cancel lock | Not from terminal | SaleOrderStatuses.isTerminal() |
| Revert lock | PROCESSING only | SaleOrderStatuses.canRevertToCart() |
Quantity ≤ 0 means soft delete — SaleOrderItemService._buildUpdateData() sets deletedAt when quantity is 0 or negative:
private _buildUpdateData(data: TUpdateItemData): Partial<TSaleOrderItem> {
if (data.quantity !== undefined && data.quantity <= 0) {
return { deletedAt: new Date() };
}
return { quantity: String(data.quantity) };
}11. Related Documentation
| Document | Description |
|---|---|
| Checkout Flow | CheckoutService validation, DRAFT→PROCESSING, revert logic |
| Payment Webhooks | PaymentWebhookService routing, SaleOrder/SaleCheck handlers, Kafka emit |
| Check Splitting | SaleCheck system for bill splitting, check operations, check payment |
| Order Operations | Order merge, merge rollback, order split with transfer history |
| Sale Service Overview | Architecture, components, binding keys |