Skip to content

Check Splitting (Bill Split)

1. Overview

In F&B dine-in, a group orders together but wants to pay separately. The SaleCheck system divides a single SaleOrder into independently-payable billing groups called checks. Each check has its own total and can be paid individually. When all checks are paid, the parent order auto-completes.

Key design principles:

PrincipleDescription
No price recalculationCheck items carry proportional shares of already-calculated prices. Prices are never re-entered into the pricing engine.
Items never moveSaleCheckItem is a reference (FK) to SaleOrderItem, not a copy. Stock is deducted once at the order level.
Additive schemaTwo new tables (SaleCheck, SaleCheckItem). Zero changes to the existing SaleOrder table schema (only a guard field added).
Lightweight lifecycleChecks use a 4-state lifecycle: PROCESSING -> COMPLETED (via payment). No complex state machine.

Source: SaleCheckService in packages/sale/src/services/sale-check.service.ts, entity schemas in packages/core/src/models/schemas/sale/sale-check/.


2. Design Rationale

Why SaleCheck instead of Parent/Child Orders

The alternative approach -- splitting an order into parent/child SaleOrder records -- was evaluated and rejected. SaleCheck is a lightweight billing group within an order, which avoids the following problems:

Problem with Parent/ChildHow SaleCheck Solves It
Price recalculation -- child orders re-enter PricingNetworkService. "Buy 3 get 1 free" breaks when split into qty=2 + qty=1. Volume tax brackets change.Never recalculated. checkItem.tax = orderItem.tax * (checkQty / orderQty)
Inventory double-counting -- parent deducts 3, children deduct 2+1 = 6 total. End-of-day stock won't reconcile.Items never move. SaleCheckItem is a reference (FK), not a copy. Stock deducted once at order level.
Lifecycle explosion -- N+1 state machines. Parent needs SPLIT/PARTIALLY_COMPLETED. Every handler needs parent/child awareness.Order stays in PROCESSING. Check lifecycle (PROCESSING -> COMPLETED via payment). Order auto-completes when all checks COMPLETED.
Reporting corruption -- order count inflates, revenue doubles unless every query adds WHERE parentOrderId IS NULL.Zero impact. Order count, revenue, AOV unchanged. Checks are invisible to top-level analytics.
Destructive rollback -- unsplitting = delete children + restore parent + recalculate pricing + handle partial payments.Soft-delete checks + items. Order items untouched.
Schema pollution -- SaleOrder gains parentOrderId, isChild, splitType. Every query must exclude children.Zero changes to SaleOrder table. Additive only (3 new tables).
Concurrency -- splitting requires distributed locking across parent + all children.Single SELECT ... FOR UPDATE on parent order row. One lock, one row, atomic.

Industry Precedent

Toast, Square, Oracle MICROS, Lightspeed, and Clover all use the "check within order" pattern. No major F&B POS uses parent/child orders for bill splitting.

Trade-offs Accepted

  • 2 new tables vs 0 new tables but hundreds of lines of parent/child lifecycle logic
  • Payment webhook has dual path (direct order vs check-based) -- clean if/else, not a maintenance burden
  • Check totals may have rounding differences vs order total (max 0.0001 per item, remainder assigned to first check)
  • Cannot split DRAFT orders -- by design, prices must be finalized first

3. Data Model

3.1 Entity Relationship

Source: packages/core/src/models/schemas/sale/sale-check/schema.ts, sale-check-item/schema.ts.

3.2 SaleCheck Columns

ColumnTypeNotes
idsnowflakePK
saleOrderIdFK -> SaleOrderRequired
statustextPROCESSING (default), PARTIAL, COMPLETED, CANCELLED
subtotaldecimal(15,4)Calculated from items
taxdecimal(15,4)Calculated from items
discountdecimal(15,4)Calculated from items
totaldecimal(15,4)Calculated from items
customerIdFK -> CustomerOptional payer
createdByFK -> UserAudit
modifiedByFK -> UserAudit

3.3 SaleCheckItem Columns

ColumnTypeNotes
idsnowflakePK
saleCheckIdFK -> SaleCheckRequired
saleOrderItemIdFK -> SaleOrderItemReference, not copy
quantitydecimal(15,4)Can be partial (e.g., 0.5)
subtotaldecimal(15,4)Proportional to quantity ratio
taxdecimal(15,4)Proportional to quantity ratio
discountdecimal(15,4)Proportional to quantity ratio
totaldecimal(15,4)Proportional to quantity ratio

3.4 The checkSplitAt Guard Field

SaleOrder.checkSplitAt is a nullable timestamp field that acts as the primary concurrency guard for check splitting.

AspectDetail
Typetimestamp with time zone, nullable
Set whensplit() or splitEqual() creates checks
Cleared whenrollback() removes all checks (set back to null)
PurposePrevents double-split -- blocks a second split while checks are active

Guard matrix:

OperationcheckSplitAt = nullcheckSplitAt != null
Split checksSet checkSplitAt = now()400: Already split
Split orderSet orderSplitAt = now()400: Has checks
Merge ordersAllowed400: Has checks
Rollback checks--Set checkSplitAt = null

Concurrency safety: The guard check runs inside SELECT FOR UPDATE on the order row. Two concurrent splits: the first acquires the lock and sets checkSplitAt; the second waits, reads non-null checkSplitAt, and rejects with 400.

3.5 Proportional Calculation Formula

Check item financial fields are calculated as proportional shares of the parent order item:

typescript
checkItem.subtotal = orderItem.unitPrice * checkItem.quantity;
checkItem.tax      = orderItem.tax * (checkItem.quantity / orderItem.quantity);
checkItem.discount = orderItem.discount * (checkItem.quantity / orderItem.quantity);
checkItem.total    = orderItem.total * (checkItem.quantity / orderItem.quantity);

NOTE

Prices are never recalculated through the pricing engine. This preserves volume discounts, bundle pricing, and coupons that were applied at the order level.

3.6 Key Constraint: Quantity Integrity

For every SaleOrderItem, the sum of SaleCheckItem.quantity across all checks must equal the order item's total quantity:

SUM(SaleCheckItem.quantity WHERE saleOrderItemId = X) == SaleOrderItem.quantity

This is enforced at the application level during split validation. A tolerance of 0.0001 is applied to handle floating-point rounding in proportional splits.


4. SaleCheck Lifecycle

Status Transitions

State Table

FromToTriggerService
(created)PROCESSINGsplit() or splitEqual()SaleCheckService (order in PROCESSING/PARTIAL, checkSplitAt null)
PROCESSINGPARTIALwebhook ATTEMPT_SUCCESS, paid < totalSaleCheckPaymentWebhookService._handleCheckPaymentSuccess
PROCESSING / PARTIALCOMPLETEDwebhook ATTEMPT_SUCCESS, paid >= totalsame; on full payment, may cascade to order COMPLETED via _checkOrderCompletionViaChecks
PROCESSINGCANCELLEDwebhook ATTEMPT_FAILED / EXPIRED / CANCELLED_handleCheckPaymentFailed / _Expired / _Cancelled
PROCESSING(soft-delete)rollback()SaleCheckService — no check may be COMPLETED
PROCESSING(soft-delete)merge() source checksSaleCheckService — all checks in PROCESSING

SaleCheck supports partial payment (PARTIAL status). The order auto-completes when ALL checks reach COMPLETED — see _checkOrderCompletionViaChecks in SaleCheckPaymentWebhookService. Merged or rolled-back checks are soft-deleted, not set to CANCELLED.


5. Operations

5.1 Split (Manual)

Manually assign order items to named checks.

Endpoint: POST /v1/api/sale/sale-orders/{id}/checks/split

Request body:

typescript
{
  checks: [
    {
      customerId?: string;      // Optional payer
      items: [
        {
          saleOrderItemId: string;
          quantity: string;        // String decimal (e.g., "0.5")
        }
      ]
    }
  ]
}

Validation chain:

StepHTTPCondition
1404Order not found
2400Order status is not PROCESSING or PARTIAL
3400checkSplitAt is not null (already split)
4400A check in the request has zero items
5400Any item quantity <= 0
6400An order item is not assigned to any check
7400Sum of assigned quantities != order item quantity

Happy path:

  1. Lock order (SELECT ... FOR UPDATE)
  2. Load order items
  3. Validate all items assigned with correct quantities
  4. Create SaleCheck records (status: PROCESSING)
  5. Create SaleCheckItem records with proportional totals
  6. Set checkSplitAt = now() on the order
  7. Recalculate check totals via SQL aggregation
  8. Write audit log (action: SPLIT)
  9. Commit transaction
  10. Emit WebSocket event: sale.check.created

WARNING

Audit logging and WebSocket notifications for check operations are not yet implemented in the current codebase. The service methods execute the database operations but do not emit events.

Source: SaleCheckService.split() in packages/sale/src/services/sale-check.service.ts.

5.2 Split Equal (Auto Split)

Automatically distribute all items across N equal checks.

Endpoint: POST /v1/api/sale/sale-orders/{id}/checks/split-equal

Request body:

typescript
{
  count: number;                          // 2-10
  mode: 'integer' | 'proportional';       // default: 'proportional'
  names?: string[];                       // optional check labels
}

Two distribution modes:

Integer Mode

Distributes whole units first, fractional leftover to the next check. Best for items that don't make sense as fractions (e.g., "1 steak" not "0.6667 steaks").

Item QtyCountDistributionExplanation
73[3, 2, 2]base=2, remainder=1 -> +1,+0,+0
7.53[3, 2.5, 2]base=2, remainder=1.5 -> +1,+0.5,+0
23[1, 1, 0]base=0, remainder=2 -> +1,+1,+0
104[3, 3, 2, 2]base=2, remainder=2 -> +1,+1,+0,+0

Proportional Mode (Default)

Fractional quantities rounded to 4 decimal places. Last check absorbs rounding remainder. Best for ensuring every check has the same total ("split evenly" UX).

Item QtyCountDistribution
23[0.6667, 0.6667, 0.6666]
73[2.3333, 2.3333, 2.3334]

Internal flow: splitEqual() builds a TSplitCheckRequest from the calculated buckets and delegates to split().

Source: SaleCheckService.splitEqual() in packages/sale/src/services/sale-check.service.ts.

5.3 Update Check

Modify an existing PROCESSING check -- reassign customer or reassign items.

Endpoint: PUT /v1/api/sale/sale-checks/{checkId}

Request body:

typescript
{
  customerId?: string;
  items?: [
    {
      saleOrderItemId: string;
      quantity: string;
    }
  ]
}

Validation:

HTTPCondition
400Check not found or status is not PROCESSING

Happy path:

  1. Validate check exists and is PROCESSING
  2. Update customer if provided
  3. If items provided: soft-delete existing check items, create new ones with recalculated proportional totals
  4. Recalculate check totals
  5. Commit transaction

5.4 Merge Checks

Combine two or more checks on the same order into a single target check.

Endpoint: POST /v1/api/sale/sale-orders/{id}/checks/merge

Request body:

typescript
{
  sourceCheckIds: string[];   // Checks to merge FROM
  targetCheckId: string;      // Check to merge INTO
}

Validation:

HTTPCondition
400Any check (source or target) not found or status is not PROCESSING

Happy path:

  1. Lock order (SELECT ... FOR UPDATE)
  2. Validate all check IDs exist and are PROCESSING
  3. Move all items from source checks to target check
  4. Soft-delete source checks
  5. Recalculate target check totals
  6. Commit transaction

5.5 Rollback Checks

Remove all checks from an order, returning it to the unsplit state.

Endpoint: DELETE /v1/api/sale/sale-orders/{id}/checks

Validation:

HTTPCondition
400No checks exist on the order
400Any check has status COMPLETED

Happy path:

  1. Lock order (SELECT ... FOR UPDATE)
  2. Load all checks for the order
  3. Validate none are COMPLETED
  4. Soft-delete all check items and checks
  5. Clear checkSplitAt to null on the order
  6. Commit transaction

NOTE

Rollback is an all-or-nothing operation. Individual checks cannot be selectively rolled back -- use merge to consolidate checks instead.


6. Check Payment

Payment Webhook Flow

When the payment gateway sends a webhook with sourceType: SaleCheck, PaymentWebhookService (router) extracts the checkId and dispatches to SaleCheckPaymentWebhookService.handleCheckEvent. See Payment Webhooks for the full router contract.

Source: SaleCheckPaymentWebhookService in packages/sale/src/services/sale-check-payment-webhook.service.ts.

Auto-Completion Rule

When the last PROCESSING/PARTIAL check on an order transitions to COMPLETED (via full payment), the parent SaleOrder automatically transitions to COMPLETED (_checkOrderCompletionViaChecks). Customer points are awarded if order.customerId is set. Note: the order-side path also marks AllocationUsage → SUCCESS; the check-side cascade does not explicitly do that today.


7. Full Lifecycle Example

End-to-end flow: split an order into 2 checks, pay each check individually, order auto-completes.

Split, Merge, Re-split Scenario


8. API Endpoints

MethodEndpointDescriptionSection
POST/v1/api/sale/sale-orders/{id}/checks/splitManual split into checks5.1
POST/v1/api/sale/sale-orders/{id}/checks/split-equalAuto-distribute items across N equal checks5.2
PUT/v1/api/sale/sale-checks/{checkId}Update check customer or items5.3
POST/v1/api/sale/sale-orders/{id}/checks/mergeMerge source checks into target check5.4
DELETE/v1/api/sale/sale-orders/{id}/checksRollback all checks on the order5.5

Source: Route definitions in packages/sale/src/controllers/sale-check/definitions.ts.


9. WebSocket Events

WARNING

WebSocket notifications for check operations are not yet implemented. The notifyCheckEvent() calls in SaleCheckService are currently commented out. The events below are the planned design.

All check events will be published on topic observation/sale/sale-check and broadcast to the order's rooms.

EventTrigger
sale.check.createdsplit() / splitEqual()
sale.check.updatedupdateCheck()
sale.check.mergedmerge()
sale.check.rolledBackrollback()
sale.check.completedPayment webhook ATTEMPT_SUCCESS on a check

Source: SaleSocketEventService.notifyCheckEvent() in packages/sale/src/components/websocket/socket-event.service.ts.


DocumentDescription
Sale OrderSaleOrder entity, statuses, and core lifecycle
Order OperationsOrder-level merge and split (distinct from check splitting)
PaymentsPayment processing and webhook handling
WebSocket EventsReal-time event system and topics

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