Order Operations (Merge & Split)
1. Overview
The Sale Service provides three order-level operations for combining and dividing SaleOrders. These are distinct from check splitting, which creates billing groups (SaleChecks) within a single order.
| Operation | Direction | Description |
|---|---|---|
| Merge Orders | N -> 1 | Combine items from multiple source orders into one target order |
| Rollback Merge | 1 -> N | Reverse a merge, restoring source orders and moving items back |
| Split Order | 1 -> N | Divide a single order into multiple independent orders |
All three operations use transfer history tracking to record item movement across orders, enabling full audit trails and rollback capability.
Source: OrderMergeService and OrderSplitService in packages/sale/src/services/.
Guard Fields
Two independent timestamp fields on SaleOrder control operation eligibility:
| Field | Set when | Cleared when | Blocks |
|---|---|---|---|
checkSplitAt | Check split created | Check rollback | Merge, rollback merge, order split |
orderSplitAt | Order split performed | Never (informational) | Nothing |
WARNING
If checkSplitAt is set, the cashier must rollback checks before performing any order-level merge or split operation.
2. Transfer History
Every SaleOrderItem has a nullable JSONB column transferHistory that tracks the full chain of order-to-order movements.
Source: TTransferHistoryEntry type in packages/core/src/models/schemas/sale/sale-item/model.ts.
Type Definition
export type TTransferHistoryEntry = {
sourceOrderId: string; // Order the item was in BEFORE this transfer
targetOrderId: string; // Order the item was transferred TO
transferredAt: string; // ISO timestamp of the transfer
};Interpretation Rules
| Value | Meaning |
|---|---|
null | Item is native to its current order (never transferred) |
[entry] | Item was transferred once (single merge or split) |
[entry1, entry2, ...] | Item has a full transfer chain (chained merges) |
Chained Merge Example (C -> B -> A)
Step 1 -- Item native to Order C, merge C into B:
{
"transferHistory": [
{
"sourceOrderId": "C",
"targetOrderId": "B",
"transferredAt": "2026-03-30T01:00:00Z"
}
]
}Step 2 -- Merge B into A. The same item gains a second entry:
{
"transferHistory": [
{
"sourceOrderId": "C",
"targetOrderId": "B",
"transferredAt": "2026-03-30T01:00:00Z"
},
{
"sourceOrderId": "B",
"targetOrderId": "A",
"transferredAt": "2026-03-30T02:00:00Z"
}
]
}Key properties of the array:
transferHistory[0].sourceOrderId-- the original order this item was created ontransferHistory[length - 1]-- the most recent transfer- Rollback pops the last entry; if the array becomes empty, it resets to
null
3. Merge Orders
Combine items from one or more source orders into a single target order.
3.1 Endpoint
POST /v1/api/sale/sale-orders/mergeRequest body:
{
sourceOrderIds: string[]; // Orders to merge FROM
targetOrderId: string; // Order to merge INTO
}3.2 Validation
| HTTP | Condition |
|---|---|
| 400 | Target order is not PROCESSING. Sources must be DRAFT or PROCESSING. |
| 400 | Source not found via findOne({ where: { id, merchantId, saleChannelId } }) -- combines existence + merchant/branch validation |
| 400 | Any order has checkSplitAt set -- rollback checks first |
3.3 Happy Path Flow
Source: OrderMergeService.mergeOrders() in packages/sale/src/services/order-merge.service.ts.
3.4 Design Decisions
| Decision | Rationale |
|---|---|
| No deduplication | Transferred items remain as separate lines on the target order, even if the same product already exists. This preserves pricing integrity from the source order and enables rollback. |
| Keep original pricing | Items are NOT re-priced after merge. Each item retains its price from the source order. |
| Same merchant + channel | All orders must share merchantId and saleChannelId. Validated via a single findOne() query. |
| Ascending ID lock order | All orders are locked in ascending ID order to prevent deadlocks when concurrent merges target overlapping order sets. |
| checkSplitAt guard | Orders with active checks cannot be merged. This avoids check totals mismatch, audit trail fragmentation, and unexpected auto-created checks. |
4. Rollback Merge
Reverse a merge operation -- restore cancelled source orders and move transferred items back.
4.1 Endpoint
DELETE /v1/api/sale/sale-orders/{id}/rollbackRequest body: None -- rolls back ALL merged sources at once. No selective un-merge.
4.2 Validation
| HTTP | Condition |
|---|---|
| 400 | Order is not PROCESSING |
| 400 | No items with transferHistory found (nothing to rollback) |
| 400 | checkSplitAt is set -- rollback checks first |
| 400 | Source order not found or not CANCELLED with reason MERGED_INTO_{targetId} |
4.3 Happy Path Flow
Source: OrderMergeService.rollbackMerge() in packages/sale/src/services/order-merge.service.ts.
4.4 Chained Rollback Behavior
When merges are chained (C -> B -> A), rollback is progressive -- each rollback only reverses the most recent merge hop:
| Step | Action | Result |
|---|---|---|
| 1 | Merge C into B | B has C's items (history: [{C->B}]) |
| 2 | Merge B into A | A has all items (history: [{C->B}, {B->A}]) |
| 3 | Rollback A | A loses B's items. B restored to PROCESSING with C's items still in it (history: [{C->B}]). |
| 4 | Rollback B | B loses C's items. C restored to PROCESSING with native items (history: null). |
5. Order Split
Divide a single order into multiple independent orders. Each new order is re-priced through PricingNetworkService to reflect accurate volume discounts and promotions.
5.1 Endpoint
POST /v1/api/sale/sale-orders/{id}/splitRequest body:
{
orders: [
{
name?: string; // Optional display name for new order
customerId?: string; // Optional customer assignment
items: [
{
saleOrderItemId: string; // Which item to move
quantity: number; // How much to move (can be partial)
}
]
}
// ... more order groups (at least 1 required) ]
}Response:
{
originalOrder: TSaleOrder; // Updated original (may have remaining items)
newOrders: TSaleOrder[]; // Newly created orders
}5.2 Validation
| HTTP | Condition |
|---|---|
| 400 | Order is not PROCESSING |
| 400 | checkSplitAt is set (has active checks -- rollback checks first) |
| 400 | Referenced saleOrderItemId not found on the order |
| 400 | Quantity must be positive (> 0) |
| 400 | Sum of assigned quantities for an item exceeds its total quantity |
| 400 | At least one order group required |
| 400 | Each order group must have at least one item |
5.3 Happy Path Flow
Source: OrderSplitService.split() in packages/sale/src/services/order-split.service.ts.
5.4 Quantity Splitting
A single item can be distributed across multiple order groups with partial quantities.
Example: An item with quantity = 5 split into two groups:
| Group | Assigned Qty | Mechanism |
|---|---|---|
| Group A | 2 | Partial split -- original item qty reduced to 3; new item created with qty 2 on new order |
| Group B | 3 | Full move (remaining qty) -- item moved entirely to new order |
Items not assigned to any group remain on the original order.
IMPORTANT
After splitting, every affected order (original + new orders) is re-priced through PricingNetworkService.calculate(). Volume discounts and promotions are recalculated based on actual quantities per order. This means prices may change from the original order.
5.5 Undo = Merge
There is no dedicated "undo split" operation. Instead, use mergeOrders() to recombine split orders:
- Call
POST /sale-orders/mergewithtargetOrderId = originalandsourceOrderIds = [newOrderA, newOrderB] - Items move back with transfer history updated
- All orders are re-priced (pricing changes again -- this is expected)
If the original order was cancelled (all items moved out), merge into one of the new orders instead.
5.6 The orderSplitAt Field
When an order split occurs, orderSplitAt is set to the current timestamp on both the original order (if it retains items) and the cancelled original (if all items moved out). This field is informational only -- it does not block any subsequent operations.
checkSplitAt | orderSplitAt | Meaning |
|---|---|---|
| null | null | Fresh order -- no splits of any kind |
| set | null | Check-split only -- has active checks |
| null | set | Order-split performed, remaining items, no checks |
| set | set | Order-split + check-split on remaining items |
6. API Endpoints
| Method | Path | Operation | Service |
|---|---|---|---|
| POST | /v1/api/sale/sale-orders/merge | Merge Orders | OrderMergeService.mergeOrders() |
| DELETE | /v1/api/sale/sale-orders/{id}/rollback | Rollback Merge | OrderMergeService.rollbackMerge() |
| POST | /v1/api/sale/sale-orders/{id}/split | Split Order | OrderSplitService.split() |
7. WebSocket Events
NOTE
WebSocket events for order merge/split operations are defined but not yet implemented in the current codebase. The event emissions are commented out in the service layer. The events listed below are planned for a future release.
All order-level events are broadcast on topic observation/sale/sale-order.
| Event | Trigger | Payload |
|---|---|---|
sale.order.merged | mergeOrders() completes | Target order + cancelled source order IDs |
sale.order.mergeRolledBack | rollbackMerge() completes | Target order + restored source order IDs |
sale.order.split | OrderSplitService.split() completes | Original order + new orders |
8. COMBO Bundle Operations
When a combo PV is in the order, the lead SaleOrderItem plus its N children (linked via leadItemId) form an atomic unit. Edit and split flows enforce this:
8.1 Item updates — lead-driven cascade
SaleOrderItemService.update:
| Scenario | Behavior |
|---|---|
Patch a child (row with leadItemId !== null) | Reject COMBO_CHILD_EDIT_FORBIDDEN |
| Patch a lead's quantity (1 → N) | Scale every child's quantity by N/old; emit per-child applyReservationDelta for the resulting delta; persist each child's new quantity individually |
| Patch a lead's quantity to ≤ 0 | Soft-delete every child + the lead; release all per-child reservations |
The reservation cascade runs inside the same transaction as the lead's update — a leaf failing the forceNonNegative SQL guard rolls back the whole operation.
8.2 Order split — combo atomicity
OrderSplitService._assertCombosAtomicAcrossGroups rejects requests that would orphan a combo:
| Violation | Error |
|---|---|
| Group contains a lead but not all its children | COMBO_SPLIT_NOT_ATOMIC |
| Group contains a child but not its lead | COMBO_SPLIT_NOT_ATOMIC |
| Group assigns a partial quantity to any combo member | COMBO_SPLIT_NOT_ATOMIC |
A valid combo split moves every combo member (lead + children) into the same target group, each with its full row quantity.
8.3 Order merge
Merge moves whole orders; combo grouping is preserved automatically because leadItemId propagates with the row. No extra guard.
ADR: developer/packages/inventory/decisions/0006-combo-explosion-at-cart-add.
9. Related Documentation
- Sale Order -- SaleOrder entity, status lifecycle, item modes
- Payments -- Payment processing and webhook handling
- WebSocket -- Real-time event system and topics