Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions docs/architecture/order-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ same fields:

Non-cash lines can never exceed the balance (`400`). Cash is applied up to the amount still owed and
the difference is recorded as change, so an over-tendered bill settles and its ledger line carries
`tendered_amount` and `change_amount`.
`tendered_amount` and `change_amount`. When a bill's remaining balance is zero, any positive payment
tender (cash or non-cash) is rejected (`400` with `payment_exceeds_balance`), while an omitted-amount
payment line completes zero-balance settlement.

The optional `Idempotency-Key` is actor-scoped: the same key with the same normalized request
returns the committed response, and the same key with a different request is a `409`. Concurrent
Expand All @@ -162,8 +164,10 @@ Two gates sit between a valid request and the write:
When all kitchen items are delivered, `override_pin` verification is bypassed. The override is
recorded in the order audit log.

A paid bill refuses further payment (`400`) and a refunded bill refuses it with `409`. The response
carries the updated bill plus the wallet and loyalty effects; `bill.payment_details` is the
A paid bill refuses positive payment (`400` with `bill_already_paid`) and a refunded bill refuses
payment with `409`. A zero-balance payment retry on an already-settled bill returns `200`
idempotently without rejecting. The response carries the updated bill plus the wallet and loyalty
effects; `bill.payment_details` is the
settlement ledger, one entry per line with the applied amount, the requested amount, cash tendered
and change, the `cash_session_id`, and the timestamp.

Expand All @@ -174,6 +178,28 @@ When a payment settles the last unpaid bill of an order, the order becomes `comp
already completed or cancelled) and its dine-in table is released to `available`. A payment never
resurrects a terminal order.

When an order, item, or bill discount reduces the remaining balance of a partially paid bill to exactly
zero (where collected funds equal the discounted total), auto-settlement to `paid` is gated by kitchen
delivery status. When `require_kitchen_delivered_before_settlement` is enabled, `kds_enabled` is not
`false`, and `billing_type` is not `prepaid`:

- If kitchen items remain undelivered (`pending`, `preparing`, or `ready`), auto-settlement is held:
the discount applies and reduces the balance to zero, but the bill remains in `partial` status
(`paid_at` is unset), the order stays active, and the dine-in table remains occupied. Supplying a
valid manager PIN as `override_pin` (or via an explicit approver) overrides the gate, recording
`kitchen_delivery_override` in `order_audit_log` and allowing auto-settlement to proceed immediately.
- Once undelivered kitchen items are served (or when settled with an `override_pin`), a zero-balance
payment retry settles the bill to `paid`.

When all kitchen items are delivered (or kitchen-delivery enforcement does not apply), the bill
automatically settles to `paid` with its `paid_at` timestamp recorded. If all sibling bills are
settled, the order transitions to `completed` and releases its dine-in table to `available`. Loyalty
cashback is credited on collected net funds, and no phantom payment lines are written to
`bill.payment_details`. A zero-balance settlement retry on an already settled bill returns `200`
idempotently without rejecting. Discounts that would reduce the bill total below already-collected
payments are rejected (`409` with diagnostic code `below_collected_funds`). Discounts on orders with
zero collected payments leave the bill `unpaid` until explicitly settled.

Wallet lines debit the customer's loyalty ledger. A paid bill credits cashback when loyalty is
enabled, excluding the share funded by wallet points.

Expand Down Expand Up @@ -207,6 +233,7 @@ movements, and refunds have their own end-of-business-day cutoff described above
```sh
npm run test:integration-order-hold-payment # hold, discount, tax, installments, gates, replay
npm run test:financial-safety-remediation # payment replay idempotency, PIN decoupling, discount revalidation
npm run test:issue-exact-balance-settlement # auto-settle bill and complete order on exact-balance discount
npm run test:integration-happy # order to payment, discount recalculated
npm run test:integration-payments # payment methods and split lines
npm run test:integration-discount # discount edge cases
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ and settlement - is in [Order lifecycle](../architecture/order-lifecycle.md).
| `PATCH` | `/:id/status` | `orders.status.update` (default roles: `ROLE_ACCESS.orderStatus`) + order write limiter | path: `id`; body: `status`, `reason`, `override_pin`, `free_table`, `approver_id`, `manager_id`, `user_id` | Order-level transition. Allowed targets: `pending` to `preparing`, `ready`, `served`, `completed`, `cancelled`; `preparing` to `ready`, `served`, `completed`, `cancelled`; `ready` to `served`, `completed`, `cancelled`; `served` to `completed`, `cancelled`. `completed` and `cancelled` are terminal. Repeating the current status is a no-op. See the status safeguard rules below. |
| `PATCH` | `/:id/customer` | `ROLE_ACCESS.ownerManager` + order write limiter | path: `id`; body: `customer_id` | - |
| `PATCH` | `/:id/convert-to-takeaway` | `ROLE_ACCESS.sales` + order write limiter | path: `id` | - |
| `PATCH` | `/:id/discount` | `ROLE_ACCESS.ownerManagerCashier` + order write limiter | path: `id`; body: `discount_type`, `discount_value`, `discount_reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the order is completed or cancelled (`order_not_discountable`). `409` on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (`below_collected_funds`). Audited as `order_discount_applied`. |
| `PATCH` | `/:id/items/:itemId/discount` | `ROLE_ACCESS.ownerManagerCashier` + order write limiter | path: `id`, `itemId`; body: `discount_type`, `discount_value`, `override_pin`, `approver_id`, `manager_id`, `user_id` | Returns `{ item }` with updated `edit_version`. `400` on a completed or cancelled order, or a cancelled, voided, void-adjustment, or refunded item. `409` on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (`below_collected_funds`). |
| `PATCH` | `/:id/discount` | `ROLE_ACCESS.ownerManagerCashier` + order write limiter | path: `id`; body: `discount_type`, `discount_value`, `discount_reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the order is completed or cancelled (`order_not_discountable`). `409` on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. |
| `PATCH` | `/:id/items/:itemId/discount` | `ROLE_ACCESS.ownerManagerCashier` + order write limiter | path: `id`, `itemId`; body: `discount_type`, `discount_value`, `override_pin`, `approver_id`, `manager_id`, `user_id` | Returns `{ item }` with updated `edit_version`. `400` on a completed or cancelled order, or a cancelled, voided, void-adjustment, or refunded item. `409` on a split or refunded bill, or when the discount would reduce a partially paid bill's total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). |
| `PATCH` | `/:orderId/items/:itemId/cancel` | any authenticated role + item cancel limiter, then an in-handler permission check | path: `orderId`, `itemId`; body: `override_pin`, `reason`, `approver_id`, `manager_id`, `user_id` | See the note below. Returns `{ order: { ...order, items } }`. |
| `PATCH` | `/:orderId/items/:itemId/edit` | `orders.item.edit` (shipped to `ROLE_ACCESS.owner`) + order write limiter | path: `orderId`, `itemId`; header: `Idempotency-Key`; body: `expected_edit_version` (non-negative integer matching the item's `edit_version`, derived from the latest `order_audit_log.id` for the item or `0`), and exactly one of `quantity` or `replacement` (`product_id`, `quantity`, optional `variant_id`, `modifier_selection`, `addons`, `special_instructions`), optional `reason` | Returns `{ order: { ...order, items } }` where hydrated items carry `edit_version` and, on `cancelled` or `voided` rows marked `superseded: true` in `item_edited`, `superseded: true`. Honours `Idempotency-Key` (replaying a matching key + request before stale-version or terminal-item checks, `409` on key reuse with a different request) and rejects a stale `expected_edit_version` with `409`. On a `pending` line, restores recorded product/add-on/recipe stock once, cancels the old line as superseded (`superseded: true`), and inserts and deducts the new `pending` line; the new line keeps the stored variant and add-on selections even if the catalog has since disabled them (reductions preserve stored selections and scale historical recipe snapshots and inventory deductions onto the new pending line; replacements and increases re-validate against the current catalog, and the carried `variant_selection` snapshot is persisted verbatim). On a line the kitchen already started (`preparing`, `ready`, `served`), a quantity increase keeps the original line and inserts/deducts only the extra units as a new `pending` line (`superseded: false`); a quantity decrease updates the existing line's billable quantity in place without restoring or re-deducting stock, scaling its held `inventory_deducted_quantity`, add-on deductions, and `recipe_snapshot` to the remaining quantity so wasted units stay out of available stock, while preserving its kitchen status and stored unit/add-on prices and recording `wasted_quantity` in `item_edited`; product replacement is disallowed once kitchen preparation has started (`400`). Re-syncs unpaid and partially paid bills. `409` on a split, paid, or refunded order, or when the new total falls below the amount already collected. Audited as `item_edited`. |
| `PATCH` | `/:orderId/items/:itemId/restore` | in-handler `orders.item.restore` (shipped to `ROLE_ACCESS.ownerManager`) | path: `orderId`, `itemId` | Returns `{ order: { ...order, items } }`. `400` on a completed, cancelled, or paid order, or when the cancelled line was superseded by an item edit. Re-deducts inventory and recipe components, and rescales an order-level percentage discount. |
Expand Down Expand Up @@ -396,7 +396,7 @@ Bill generation, settlement, and completion are described end to end in
| `POST` | `/:id/split-check` | `ROLE_ACCESS.ownerManagerCashier` | body: `checks` | Body `checks` must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. `400` when a request does not allocate exactly the source's own quantities. `403` when `split_checks_enabled` is off. `409` when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks. |
| `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object, `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
| `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
| `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Audited as `order_discount_applied`. |
| `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. |
| `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. `409` when the order is cancelled. |
| `POST` | `/:id/markPrinted` | `bills.print` (`ROLE_ACCESS.ownerManager`) | path: `id` | Stamps `bills.printed_at`. |
| `POST` | `/:id/print` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `print_type` | - |
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/order-attempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ const PAYMENT_DISPLAY_CODES = [
'transaction_reference_reused',
'bill_refunded',
'bill_already_paid',
'payment_exceeds_balance',
'customer_mismatch',
'customer_unavailable',
'wallet_customer_required',
Expand Down
1 change: 1 addition & 0 deletions main/lib/operation-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const PAYMENT_DISPLAY_CODES = [
'transaction_reference_reused',
'bill_refunded',
'bill_already_paid',
'payment_exceeds_balance',
'customer_mismatch',
'customer_unavailable',
'wallet_customer_required',
Expand Down
Loading
Loading