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:
| Principle | Description |
|---|---|
| No price recalculation | Check items carry proportional shares of already-calculated prices. Prices are never re-entered into the pricing engine. |
| Items never move | SaleCheckItem is a reference (FK) to SaleOrderItem, not a copy. Stock is deducted once at the order level. |
| Additive schema | Two new tables (SaleCheck, SaleCheckItem). Zero changes to the existing SaleOrder table schema (only a guard field added). |
| Lightweight lifecycle | Checks 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/Child | How 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
| Column | Type | Notes |
|---|---|---|
id | snowflake | PK |
saleOrderId | FK -> SaleOrder | Required |
status | text | PROCESSING (default), PARTIAL, COMPLETED, CANCELLED |
subtotal | decimal(15,4) | Calculated from items |
tax | decimal(15,4) | Calculated from items |
discount | decimal(15,4) | Calculated from items |
total | decimal(15,4) | Calculated from items |
customerId | FK -> Customer | Optional payer |
createdBy | FK -> User | Audit |
modifiedBy | FK -> User | Audit |
3.3 SaleCheckItem Columns
| Column | Type | Notes |
|---|---|---|
id | snowflake | PK |
saleCheckId | FK -> SaleCheck | Required |
saleOrderItemId | FK -> SaleOrderItem | Reference, not copy |
quantity | decimal(15,4) | Can be partial (e.g., 0.5) |
subtotal | decimal(15,4) | Proportional to quantity ratio |
tax | decimal(15,4) | Proportional to quantity ratio |
discount | decimal(15,4) | Proportional to quantity ratio |
total | decimal(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.
| Aspect | Detail |
|---|---|
| Type | timestamp with time zone, nullable |
| Set when | split() or splitEqual() creates checks |
| Cleared when | rollback() removes all checks (set back to null) |
| Purpose | Prevents double-split -- blocks a second split while checks are active |
Guard matrix:
| Operation | checkSplitAt = null | checkSplitAt != null |
|---|---|---|
| Split checks | Set checkSplitAt = now() | 400: Already split |
| Split order | Set orderSplitAt = now() | 400: Has checks |
| Merge orders | Allowed | 400: 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:
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.quantityThis 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
| From | To | Trigger | Service |
|---|---|---|---|
| (created) | PROCESSING | split() or splitEqual() | SaleCheckService (order in PROCESSING/PARTIAL, checkSplitAt null) |
| PROCESSING | PARTIAL | webhook ATTEMPT_SUCCESS, paid < total | SaleCheckPaymentWebhookService._handleCheckPaymentSuccess |
| PROCESSING / PARTIAL | COMPLETED | webhook ATTEMPT_SUCCESS, paid >= total | same; on full payment, may cascade to order COMPLETED via _checkOrderCompletionViaChecks |
| PROCESSING | CANCELLED | webhook ATTEMPT_FAILED / EXPIRED / CANCELLED | _handleCheckPaymentFailed / _Expired / _Cancelled |
| PROCESSING | (soft-delete) | rollback() | SaleCheckService — no check may be COMPLETED |
| PROCESSING | (soft-delete) | merge() source checks | SaleCheckService — all checks in PROCESSING |
SaleCheck supports partial payment (PARTIAL status). The order auto-completes when ALL checks reach COMPLETED — see
_checkOrderCompletionViaChecksinSaleCheckPaymentWebhookService. 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:
{
checks: [
{
customerId?: string; // Optional payer
items: [
{
saleOrderItemId: string;
quantity: string; // String decimal (e.g., "0.5")
}
]
}
]
}Validation chain:
| Step | HTTP | Condition |
|---|---|---|
| 1 | 404 | Order not found |
| 2 | 400 | Order status is not PROCESSING or PARTIAL |
| 3 | 400 | checkSplitAt is not null (already split) |
| 4 | 400 | A check in the request has zero items |
| 5 | 400 | Any item quantity <= 0 |
| 6 | 400 | An order item is not assigned to any check |
| 7 | 400 | Sum of assigned quantities != order item quantity |
Happy path:
- Lock order (
SELECT ... FOR UPDATE) - Load order items
- Validate all items assigned with correct quantities
- Create
SaleCheckrecords (status: PROCESSING) - Create
SaleCheckItemrecords with proportional totals - Set
checkSplitAt = now()on the order - Recalculate check totals via SQL aggregation
Write audit log (action:SPLIT)- Commit transaction
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:
{
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 Qty | Count | Distribution | Explanation |
|---|---|---|---|
| 7 | 3 | [3, 2, 2] | base=2, remainder=1 -> +1,+0,+0 |
| 7.5 | 3 | [3, 2.5, 2] | base=2, remainder=1.5 -> +1,+0.5,+0 |
| 2 | 3 | [1, 1, 0] | base=0, remainder=2 -> +1,+1,+0 |
| 10 | 4 | [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 Qty | Count | Distribution |
|---|---|---|
| 2 | 3 | [0.6667, 0.6667, 0.6666] |
| 7 | 3 | [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:
{
customerId?: string;
items?: [
{
saleOrderItemId: string;
quantity: string;
}
]
}Validation:
| HTTP | Condition |
|---|---|
| 400 | Check not found or status is not PROCESSING |
Happy path:
- Validate check exists and is PROCESSING
- Update customer if provided
- If items provided: soft-delete existing check items, create new ones with recalculated proportional totals
- Recalculate check totals
- 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:
{
sourceCheckIds: string[]; // Checks to merge FROM
targetCheckId: string; // Check to merge INTO
}Validation:
| HTTP | Condition |
|---|---|
| 400 | Any check (source or target) not found or status is not PROCESSING |
Happy path:
- Lock order (
SELECT ... FOR UPDATE) - Validate all check IDs exist and are PROCESSING
- Move all items from source checks to target check
- Soft-delete source checks
- Recalculate target check totals
- 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:
| HTTP | Condition |
|---|---|
| 400 | No checks exist on the order |
| 400 | Any check has status COMPLETED |
Happy path:
- Lock order (
SELECT ... FOR UPDATE) - Load all checks for the order
- Validate none are COMPLETED
- Soft-delete all check items and checks
- Clear
checkSplitAttonullon the order - 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
| Method | Endpoint | Description | Section |
|---|---|---|---|
| POST | /v1/api/sale/sale-orders/{id}/checks/split | Manual split into checks | 5.1 |
| POST | /v1/api/sale/sale-orders/{id}/checks/split-equal | Auto-distribute items across N equal checks | 5.2 |
| PUT | /v1/api/sale/sale-checks/{checkId} | Update check customer or items | 5.3 |
| POST | /v1/api/sale/sale-orders/{id}/checks/merge | Merge source checks into target check | 5.4 |
| DELETE | /v1/api/sale/sale-orders/{id}/checks | Rollback all checks on the order | 5.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.
| Event | Trigger |
|---|---|
sale.check.created | split() / splitEqual() |
sale.check.updated | updateCheck() |
sale.check.merged | merge() |
sale.check.rolledBack | rollback() |
sale.check.completed | Payment webhook ATTEMPT_SUCCESS on a check |
Source: SaleSocketEventService.notifyCheckEvent() in packages/sale/src/components/websocket/socket-event.service.ts.
10. Related Documentation
| Document | Description |
|---|---|
| Sale Order | SaleOrder entity, statuses, and core lifecycle |
| Order Operations | Order-level merge and split (distinct from check splitting) |
| Payments | Payment processing and webhook handling |
| WebSocket Events | Real-time event system and topics |