Skip to content

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

FieldTypeDescription
idtextSnowflake ID generated by IdGenerator.getInstance()
orderNumbertextFormat: YYYYMMDDHHmmss-<snowflakeId> (e.g., 20250128143025-8a7b6c5d)
nametextDisplay name — defaults to orderNumber if not provided
slugtextURL-friendly identifier: SaleOrder-<orderNumber>

Order number generation (SaleOrderService.createDraftOrder):

typescript
const orderNumber = [dayjs().format('YYYYMMDDHHmmss'), this.idGenerator.nextId()].join('-');
const slug = [SaleOrder.name, orderNumber].join('-');

Relationships

FieldTypeDescription
saleChannelIdtext FKSale channel this order belongs to
merchantIdtext FKDerived from the sale channel's merchantId at creation
createdBytext FKUser who created the order
modifiedBytext FKUser who last modified the order

Financial

FieldTypeDescription
currencytextISO 4217 code (default: VND). Fixed at creation
exchangeRatenumericExchange rate if currency conversion needed
subtotalnumericSum of all item totals (maintained by updateSummaryFromItems)
taxnumericSum of all item taxes
discountnumericSum of all item discounts
totalnumericsubtotal - discount + tax (minimum 0)

Status & Tracking

FieldTypeDescription
statustextCurrent status code (see lifecycle below)
counterjsonbPayment progress: { total, paid, paidItemIds }
validityjsonbOptional date range: { from, to }
metadatajsonbCheckout context: { merchantId, note?, finance } (set at checkout)
checkSplitAttimestampSet when checks created (split), cleared on rollback. Guards against double-split
orderSplitAttimestampSet when order is split into multiple orders. Informational -- never cleared
cancellationReasontextReason 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:

PropertyTypeSet AtDescription
merchantIdstringDraft creationMerchant reference (from sale channel)
finance.usebooleanCheckoutWhether to record income in Finance module
finance.walletIdstring?CheckoutFinance wallet ID (when use: true)
finance.categoryIdstring?CheckoutFinance category ID (when use: true)
notestring?CheckoutOptional checkout note

Timestamps

FieldSet When
draftAtOrder created (DRAFT)
processingAtCheckout (PROCESSING)
partialAtFirst partial payment (PARTIAL)
completedAtFully paid (COMPLETED)
cancelledAtCancelled (CANCELLED)
createdAtRecord created
modifiedAtRecord last modified
deletedAtSoft delete (null if active)

4. Status Lifecycle

Status Codes

StatusCodePhaseDescription
DRAFT001_DRAFTCartShopping cart — items can be added/removed/modified
PROCESSING203_PROCESSINGPaymentChecked out, awaiting payment confirmation
PARTIAL300_PARTIALPaymentSome payment received, awaiting remainder
COMPLETED303_COMPLETEDTerminalFully paid, order fulfilled
CANCELLED505_CANCELLEDTerminalOrder terminated

State Machine

Transition Rules

FromToTriggerService MethodValidation
(new)DRAFTCreate orderSaleOrderService.createDraftOrder()Valid saleChannelId required
DRAFTPROCESSINGCheckoutCheckoutService.checkout()Non-empty cart, non-negative prices, quantity ≥ 1
DRAFTCANCELLEDCancelSaleOrderService.cancelOrder()Not terminal status
PROCESSINGDRAFTRevertCheckoutService.revertCheckout()SaleOrderStatuses.canRevertToCart() returns true
PROCESSINGPARTIALPayment success (partial)SaleOrderPaymentWebhookService._handleOrderPaymentSuccesspaid < total
PROCESSINGCOMPLETEDPayment success (full)SaleOrderPaymentWebhookService._handleOrderPaymentSuccesspaid >= total
PROCESSINGCANCELLEDPayment failed/expired/cancelledSaleOrderPaymentWebhookService._handleOrderPayment{Failed,Expired,Cancelled}Status must be PROCESSING
PROCESSINGCOMPLETEDLast check COMPLETEDSaleCheckPaymentWebhookService._checkOrderCompletionViaChecksAll checks on order in SaleCheckStatuses.COMPLETED
PARTIALCOMPLETEDPayment success (full)SaleOrderPaymentWebhookService._handleOrderPaymentSuccesspaid >= total
PARTIALCANCELLEDCancelSaleOrderService.cancelOrderNot terminal status

5. SaleOrderItem Fields

Identification & Reference

FieldTypeDescription
idtextSnowflake ID
saleOrderIdtext FKParent order
modetext000_PRODUCT or 100_CUSTOM (from SaleOrderItemModes)
itemTypetextReference type (e.g., ProductVariant, CustomProductVariant)
itemIdtextExternal reference (product variant ID, or CPV_<uuid> for custom)
leadItemIdtextOriginal item ID if this is a linked item

Pricing

FieldTypeDescription
currencytextItem currency
basePricenumericOriginal price before discounts
unitPricenumericFinal price per unit
discountnumericDiscount amount
quantitynumericNumber of units (stored as string in DB, parsed as number)
taxnumericCalculated tax amount
totalnumericCalculated by updateSummaryFromItems()

Pricing Source

FieldTypeDescription
transferHistoryjsonbArray of { sourceOrderId, targetOrderId, transferredAt } entries tracking merge chain. null = native item
fareIdtextReference to pricing fare
fareProvidertextPricing provider identifier
priceMetadatajsonbContains the full fareSource object
metadatajsonbProduct 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 FieldSourceDescription
nameProductInfo.namei18n product name { default, en?, vi? }
descriptionProductInfo.descriptionProduct description (default language)
skuProductIdentifier (scheme=SKU)Stock-keeping unit
barcodeProductIdentifier (scheme=BARCODE)Barcode value
imageUrlMetaLink.linkFirst product image (most recent)
externalIdProductVariant.identifierThe PV_YYYYMMDD_xxx identifier
externalSource'ProductVariant'Literal, marks this as a server-side snapshot

Note: For CUSTOM mode items, metadata continues to use client-provided productMetadata. Custom items have no real ProductVariant to snapshot.

6. Item Modes

Product Mode (000_PRODUCT)

Source: SaleOrderService._addProductItem() (lines 295–350)

BehaviorImplementation
ReferenceLinks to existing ProductVariant by itemId + itemType
Type branchingProductVariantTypes.isCombo(variant.type)_addComboProductItem (see below). Otherwise → standard single-PV path. No category lookup
Metadata snapshotServer-side snapshot of ProductVariant data (name, SKU, barcode, image) via ProductVariantSnapshotService.buildSnapshot(). Client-provided productMetadata is ignored
PricingfareSource from FareSourceSchema (includes fareId, unitPrice, basePrice, tax)
Duplicate mergeQueries existing item by {saleOrderId, itemId, itemType}. If found, increases quantity, updates price, and refreshes the metadata snapshot. If not, creates new item with snapshot
InventoryConnected to stock tracking via Inventory Service
Row lockingParent 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:

AspectDetail
Lead rowOne SaleOrderItem with the combo PV; full price; leadItemId = null
ChildrenN SaleOrderItem rows produced by BundleExpansionService.extractComboItems. Each: leadItemId = lead.id, unitPrice = 0, basePrice = 0, tax = 0, metadata.combo = { leadVariantId, bundlerRowIds }
ReservationOne 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)
AtomicityAll reservations + inserts share the caller transaction. Any leaf STOCK_UNAVAILABLE rolls back the whole combo add
Re-add guardRe-adding the same combo throws COMBO_ALREADY_IN_ORDER. Use the edit endpoint to change quantity
Nested combosextractComboItems recurses through related variants whose own type is COMBO, with depth (MAX=5) + cycle guards. Leaves are aggregated by relatedVariantId
Empty bundlerCOMBO_HAS_NO_COMPONENTS — catalog misconfiguration surfaces at first sale
EditsLead-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.

typescript
// 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)

BehaviorImplementation
ReferenceAuto-generated: CPV_<crypto.randomUUID()>
Item typeAlways CustomProductVariant
PricingfareSource from FareSourceManualSchema (manual unitPrice, basePrice, optional tax)
No mergeEvery addition creates a new line item (no duplicate detection)
InventoryNot tracked
Row lockingSame pessimistic lock as PRODUCT mode
typescript
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:

ModeFormulaExample
AMOUNTFixed amount: valuetax = 10000 (10,000 VND flat)
PERCENTAGEunitPrice × quantity × (value / 100)50000 × 2 × (10 / 100) = 10000
(none)0No tax configured
typescript
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

typescript
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)

typescript
const AddItemRequestSchema = z.discriminatedUnion('mode', [
  // PRODUCT mode — requires itemType, itemId, FareSourceSchema
  AddItemProductRequestSchema,
  // CUSTOM mode — requires FareSourceManualSchema only
  AddItemCustomRequestSchema,
]);
FieldProduct ModeCustom Mode
mode000_PRODUCT (literal)100_CUSTOM (literal)
quantity1–9999 (default: 1)1–9999 (default: 1)
itemTypeRequired (default: ProductVariant)Not applicable
itemIdRequired (product variant ID)Not applicable (auto-generated)
fareSourceFareSourceSchema (system pricing)FareSourceManualSchema (manual pricing)
productMetadataIgnored (server-side snapshot)Optional (client-provided)

CheckoutRequest / CancelOrderRequest

typescript
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:

VariantPurpose
{ 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:

PropertyTypeDescription
totalnumberTotal amount due
paidnumberAmount paid so far
paidItemIdsstring[]List of item IDs that have been paid

Initial value (set in createDraftOrder):

typescript
counter: { total: 0, paid: 0, paidItemIds: [] },
metadata: {
  merchantId: saleChannel.merchantId,
  finance: { use: false },
}

10. Business Constraints

Source: src/common/constants.ts

ConstraintValueEnforced By
MAX_QUANTITY_PER_ITEM9999SaleOrderItemService._validateQuantity()
MAX_NUMBER_OF_ITEMS_IN_ORDER100SaleOrderService.addSaleOrderItem()
Currency lockFixed at creationCreateDraftOrderRequestSchema
Edit lockDRAFT onlySaleOrderStatuses.canModifyItems()
Cancel lockNot from terminalSaleOrderStatuses.isTerminal()
Revert lockPROCESSING onlySaleOrderStatuses.canRevertToCart()

Quantity ≤ 0 means soft deleteSaleOrderItemService._buildUpdateData() sets deletedAt when quantity is 0 or negative:

typescript
private _buildUpdateData(data: TUpdateItemData): Partial<TSaleOrderItem> {
  if (data.quantity !== undefined && data.quantity <= 0) {
    return { deletedAt: new Date() };
  }
  return { quantity: String(data.quantity) };
}
DocumentDescription
Checkout FlowCheckoutService validation, DRAFT→PROCESSING, revert logic
Payment WebhooksPaymentWebhookService routing, SaleOrder/SaleCheck handlers, Kafka emit
Check SplittingSaleCheck system for bill splitting, check operations, check payment
Order OperationsOrder merge, merge rollback, order split with transfer history
Sale Service OverviewArchitecture, components, binding keys

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.