Skip to content

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.

OperationDirectionDescription
Merge OrdersN -> 1Combine items from multiple source orders into one target order
Rollback Merge1 -> NReverse a merge, restoring source orders and moving items back
Split Order1 -> NDivide 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:

FieldSet whenCleared whenBlocks
checkSplitAtCheck split createdCheck rollbackMerge, rollback merge, order split
orderSplitAtOrder split performedNever (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

typescript
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

ValueMeaning
nullItem 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:

json
{
  "transferHistory": [
    {
      "sourceOrderId": "C",
      "targetOrderId": "B",
      "transferredAt": "2026-03-30T01:00:00Z"
    }
  ]
}

Step 2 -- Merge B into A. The same item gains a second entry:

json
{
  "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 on
  • transferHistory[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/merge

Request body:

typescript
{
  sourceOrderIds: string[];   // Orders to merge FROM
  targetOrderId: string;      // Order to merge INTO
}

3.2 Validation

HTTPCondition
400Target order is not PROCESSING. Sources must be DRAFT or PROCESSING.
400Source not found via findOne({ where: { id, merchantId, saleChannelId } }) -- combines existence + merchant/branch validation
400Any 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

DecisionRationale
No deduplicationTransferred 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 pricingItems are NOT re-priced after merge. Each item retains its price from the source order.
Same merchant + channelAll orders must share merchantId and saleChannelId. Validated via a single findOne() query.
Ascending ID lock orderAll orders are locked in ascending ID order to prevent deadlocks when concurrent merges target overlapping order sets.
checkSplitAt guardOrders 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}/rollback

Request body: None -- rolls back ALL merged sources at once. No selective un-merge.

4.2 Validation

HTTPCondition
400Order is not PROCESSING
400No items with transferHistory found (nothing to rollback)
400checkSplitAt is set -- rollback checks first
400Source 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:

StepActionResult
1Merge C into BB has C's items (history: [{C->B}])
2Merge B into AA has all items (history: [{C->B}, {B->A}])
3Rollback AA loses B's items. B restored to PROCESSING with C's items still in it (history: [{C->B}]).
4Rollback BB 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}/split

Request body:

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

typescript
{
  originalOrder: TSaleOrder;     // Updated original (may have remaining items)
  newOrders: TSaleOrder[];       // Newly created orders
}

5.2 Validation

HTTPCondition
400Order is not PROCESSING
400checkSplitAt is set (has active checks -- rollback checks first)
400Referenced saleOrderItemId not found on the order
400Quantity must be positive (> 0)
400Sum of assigned quantities for an item exceeds its total quantity
400At least one order group required
400Each 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:

GroupAssigned QtyMechanism
Group A2Partial split -- original item qty reduced to 3; new item created with qty 2 on new order
Group B3Full 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:

  1. Call POST /sale-orders/merge with targetOrderId = original and sourceOrderIds = [newOrderA, newOrderB]
  2. Items move back with transfer history updated
  3. 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.

checkSplitAtorderSplitAtMeaning
nullnullFresh order -- no splits of any kind
setnullCheck-split only -- has active checks
nullsetOrder-split performed, remaining items, no checks
setsetOrder-split + check-split on remaining items

6. API Endpoints

MethodPathOperationService
POST/v1/api/sale/sale-orders/mergeMerge OrdersOrderMergeService.mergeOrders()
DELETE/v1/api/sale/sale-orders/{id}/rollbackRollback MergeOrderMergeService.rollbackMerge()
POST/v1/api/sale/sale-orders/{id}/splitSplit OrderOrderSplitService.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.

EventTriggerPayload
sale.order.mergedmergeOrders() completesTarget order + cancelled source order IDs
sale.order.mergeRolledBackrollbackMerge() completesTarget order + restored source order IDs
sale.order.splitOrderSplitService.split() completesOriginal 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:

ScenarioBehavior
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 ≤ 0Soft-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:

ViolationError
Group contains a lead but not all its childrenCOMBO_SPLIT_NOT_ATOMIC
Group contains a child but not its leadCOMBO_SPLIT_NOT_ATOMIC
Group assigns a partial quantity to any combo memberCOMBO_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.


  • Sale Order -- SaleOrder entity, status lifecycle, item modes
  • Payments -- Payment processing and webhook handling
  • WebSocket -- Real-time event system and topics

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