diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index 417dee949..46ecb37cb 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -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 @@ -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. @@ -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. @@ -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 diff --git a/docs/reference/api.md b/docs/reference/api.md index 198c91b60..c7fbe370b 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -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. | @@ -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` | - | diff --git a/frontend/src/lib/order-attempt.ts b/frontend/src/lib/order-attempt.ts index 6ffa84e9b..219824b0e 100644 --- a/frontend/src/lib/order-attempt.ts +++ b/frontend/src/lib/order-attempt.ts @@ -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', diff --git a/main/lib/operation-failure.ts b/main/lib/operation-failure.ts index 9aa042ce8..e25b38f74 100644 --- a/main/lib/operation-failure.ts +++ b/main/lib/operation-failure.ts @@ -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', diff --git a/main/routes/bills.ts b/main/routes/bills.ts index 5ed9f4473..f3a4da8fc 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -160,7 +160,7 @@ export async function preVerifyKitchenDeliveryPin( throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); } -function checkKitchenDeliveryStatus( +export function checkKitchenDeliveryStatus( db: ReturnType, orderId: string | number, overridePin?: unknown, @@ -2969,13 +2969,25 @@ function preparePaymentBatch( if (bill.payment_status === 'refunded' || bill.payment_status === 'partially_refunded') { throw paymentValidationError('Cannot accept payment on a refunded bill', 'bill_refunded', 409); } - if (bill.payment_status === 'paid') - throw paymentValidationError('Bill is already paid', 'bill_already_paid'); const remainingCents = Math.max( 0, Math.round((Number(bill.total) - Number(bill.paid_amount || 0)) * minorFactor), ); + const hasPositiveAmount = resolvedPayments.some((p) => { + if (p.amount === null || p.amount === undefined) return false; + const num = Number(p.amount); + return !Number.isNaN(num) && num > 0; + }); + if (bill.payment_status === 'paid') { + if (hasPositiveAmount) { + throw paymentValidationError('Bill is already paid', 'bill_already_paid'); + } + return { bill, prepared: [], existingPayments, effectiveCustomerId }; + } if (remainingCents <= 0) { + if (hasPositiveAmount) { + throw paymentValidationError('Payment exceeds remaining balance', 'payment_exceeds_balance'); + } return { bill, prepared: [], existingPayments, effectiveCustomerId }; } const raw = resolvedPayments.map((payment) => { @@ -3122,6 +3134,100 @@ function calculateCashback( return Math.floor(fullOrderCashback * splitRatio); } +export function completeExactBalanceSettlement( + db: ReturnType, + billId: string | number, + newTotal: number, + currency: string, + changedAt: string = now(), + overridePin?: unknown, + clientIp = 'unknown', + preVerifiedManager?: { id: string; verifiedPinHash: string } | null, +): any { + const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; + if (!bill) return null; + + const kitchenStatus = checkKitchenDeliveryStatus( + db, + bill.order_id, + overridePin, + clientIp, + bill.id, + preVerifiedManager, + ); + if (!kitchenStatus.allowed) { + return parseRowJson(bill); + } + + db.prepare( + `UPDATE bills SET payment_status = 'paid', paid_at = COALESCE(paid_at, ?), balance = 0, updated_at = ? WHERE id = ?`, + ).run(changedAt, changedAt, billId); + + const updatedBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; + if (!updatedBill) return null; + + const unpaidSibling = db + .prepare( + `SELECT 1 FROM bills WHERE order_id = ? AND id != ? AND payment_status NOT IN ('paid', 'refunded', 'partially_refunded') LIMIT 1`, + ) + .get(bill.order_id, bill.id); + const orderFullyPaid = !unpaidSibling; + let orderRow: any = null; + if (orderFullyPaid) { + orderRow = db + .prepare('SELECT status, table_id, customer_id FROM orders WHERE id = ?') + .get(bill.order_id) as any; + const canComplete = orderRow && !['cancelled', 'completed'].includes(orderRow.status); + if (canComplete) { + db.prepare( + "UPDATE orders SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", + ).run(changedAt, changedAt, bill.order_id); + if (orderRow.table_id) { + db.prepare("UPDATE tables SET status = 'available', updated_at = ? WHERE id = ?").run( + changedAt, + orderRow.table_id, + ); + } + } + } + + const effectiveCustomerId = bill.customer_id || orderRow?.customer_id; + const cashback = calculateCashback(db, bill, effectiveCustomerId); + const alreadyCredited = db + .prepare(`SELECT id FROM loyalty_ledger WHERE bill_id = ? AND type = 'credit'`) + .get(bill.id); + if (cashback > 0 && !alreadyCredited) { + let existingPayments: any[] = []; + try { + existingPayments = JSON.parse(bill.payment_details || '[]'); + } catch { + existingPayments = []; + } + const minorFactor = getCurrencyMinorUnitFactor(currency); + const walletCents = existingPayments + .filter((p: any) => p.method === 'wallet') + .reduce((sum: number, p: any) => sum + Math.round(Number(p.amount || 0) * minorFactor), 0); + const totalCents = Math.round(Number(newTotal) * minorFactor); + const finalCashback = Math.floor( + cashback * (1 - Math.min(1, walletCents / Math.max(1, totalCents))), + ); + if (finalCashback > 0) { + db.prepare( + `INSERT INTO loyalty_ledger (customer_id, bill_id, type, amount, description, created_at, updated_at) VALUES (?, ?, 'credit', ?, ?, ?, ?)`, + ).run( + effectiveCustomerId, + bill.id, + finalCashback, + `Cashback on bill ${bill.bill_number}`, + changedAt, + changedAt, + ); + } + } + + return parseRowJson(db.prepare('SELECT * FROM bills WHERE id = ?').get(billId)); +} + function applyPaymentBatch( db: ReturnType, billId: string, @@ -3205,6 +3311,77 @@ function applyPaymentBatch( }, }); } + if (prepared.length === 0) { + const changedAt = now(); + if (bill.payment_status !== 'paid') { + db.prepare( + `UPDATE bills SET payment_status = 'paid', paid_at = COALESCE(paid_at, ?), balance = 0, updated_at = ? WHERE id = ?`, + ).run(changedAt, changedAt, billId); + } + const unpaidSibling = db + .prepare( + `SELECT 1 FROM bills WHERE order_id = ? AND id != ? AND payment_status NOT IN ('paid', 'refunded', 'partially_refunded') LIMIT 1`, + ) + .get(bill.order_id, bill.id); + if (!unpaidSibling) { + const order = db + .prepare('SELECT status, table_id, customer_id FROM orders WHERE id = ?') + .get(bill.order_id) as any; + if (order && !['cancelled', 'completed'].includes(order.status)) { + db.prepare( + "UPDATE orders SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", + ).run(changedAt, changedAt, bill.order_id); + if (order.table_id) { + db.prepare("UPDATE tables SET status = 'available', updated_at = ? WHERE id = ?").run( + changedAt, + order.table_id, + ); + } + } + } + const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; + const effectiveCustId = freshBill.customer_id; + const cashback = calculateCashback(db, freshBill, effectiveCustId); + const alreadyCredited = db + .prepare(`SELECT id FROM loyalty_ledger WHERE bill_id = ? AND type = 'credit'`) + .get(freshBill.id); + let loyaltyPointsEarned = 0; + if (cashback > 0 && !alreadyCredited) { + let existingList: any[] = []; + try { + existingList = JSON.parse(freshBill.payment_details || '[]'); + } catch { + existingList = []; + } + const currency = getTenantCurrency(); + const minorFactor = getCurrencyMinorUnitFactor(currency); + const totalCents = Math.round(Number(freshBill.total || 0) * minorFactor); + const walletCents = existingList + .filter((p: any) => p.method === 'wallet') + .reduce((sum: number, p: any) => sum + Math.round(Number(p.amount || 0) * minorFactor), 0); + const finalCashback = Math.floor( + cashback * (1 - Math.min(1, walletCents / Math.max(1, totalCents))), + ); + if (finalCashback > 0) { + db.prepare( + `INSERT INTO loyalty_ledger (customer_id, bill_id, type, amount, description, created_at, updated_at) VALUES (?, ?, 'credit', ?, ?, ?, ?)`, + ).run( + effectiveCustId, + freshBill.id, + finalCashback, + `Cashback on bill ${freshBill.bill_number}`, + changedAt, + changedAt, + ); + loyaltyPointsEarned = finalCashback; + } + } + return { + bill: parseRowJson(freshBill), + walletDebited: false, + loyaltyPointsEarned, + }; + } // Shift enforcement (#279) runs after replay detection: retrying an already // recorded payment must succeed even if its shift has since closed. requireOpenSessionForCashTender( @@ -3823,7 +4000,7 @@ router.post( asyncHandler(async (req: Request, res: Response) => { let operationCommitted = false; try { - const { type, value, reason } = req.body; + const { type, value, reason, override_pin, approver_id, user_id, manager_id } = req.body; if (!type || !['percentage', 'amount'].includes(type)) { return res @@ -3857,6 +4034,7 @@ router.post( } const db = getDatabase(); + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(req.params.id) as | ChargeBillRow | undefined; @@ -3931,7 +4109,6 @@ router.post( .status(403) .json({ error: 'Manager PIN required for discounts', requiresApproval: true }); } - const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; const rateLimitKey = `pin:${clientIp}:bill-discount`; if (isPinRateLimited(rateLimitKey)) { return res.status(429).json({ error: 'Too many PIN attempts. Try again in 15 minutes.' }); @@ -4185,12 +4362,28 @@ router.post( ); } const freshNewBalance = Math.max(0, freshNewTotal - freshPaidAmount); + const preVerifiedDiscountApprover = + approvedByUserId && preVerifiedPinHash + ? { id: approvedByUserId, verifiedPinHash: preVerifiedPinHash } + : null; + const kitchenStatus = checkKitchenDeliveryStatus( + db, + currentBill.order_id, + override_pin, + clientIp, + currentBill.id, + preVerifiedDiscountApprover, + ); + const isNowPaid = freshPaidAmount > 0 && freshNewBalance === 0 && kitchenStatus.allowed; db.prepare(` UPDATE bills SET subtotal = ?, discount_amount = ?, discount_type = ?, discount_value = ?, discount_reason = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, service_charge = ?, packaging_charge = ?, charges_breakdown = ?, - total = ?, round_off = ?, balance = ?, updated_at = ? + total = ?, round_off = ?, balance = ?, + payment_status = CASE WHEN ? = 1 THEN 'paid' ELSE payment_status END, + paid_at = CASE WHEN ? = 1 THEN COALESCE(paid_at, ?) ELSE paid_at END, + updated_at = ? WHERE id = ? `).run( freshTotals.subtotal, @@ -4207,6 +4400,9 @@ router.post( freshNewTotal, freshNewRoundOff, freshNewBalance, + isNowPaid ? 1 : 0, + isNowPaid ? 1 : 0, + now(), now(), req.params.id, ); @@ -4237,6 +4433,33 @@ router.post( currentBill.order_id, ); + if (isNowPaid) { + completeExactBalanceSettlement( + db, + String(req.params.id), + freshNewTotal, + currency, + now(), + override_pin, + clientIp, + preVerifiedDiscountApprover, + ); + } + + if (kitchenStatus.overridden && isNowPaid) { + recordOrderAudit(db, { + orderId: String(currentBill.order_id), + actorUserId: String( + (req as Request & { user?: { userId?: string } }).user?.userId || '', + ), + action: 'kitchen_delivery_override', + details: { + manager_user_id: kitchenStatus.managerUserId, + undelivered_count: kitchenStatus.count, + }, + }); + } + recordOrderAudit(db, { orderId: String(currentBill.order_id), actorUserId: String((req as Request & { user?: { userId?: string } }).user?.userId || ''), diff --git a/main/routes/orders.ts b/main/routes/orders.ts index 3869dd787..ea666bca2 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -63,7 +63,12 @@ import { hasPermission, requirePermission } from '../services/authorization'; import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; import { isTerminalItemStatus } from '../../shared/order-item-status'; import { getCurrencyFractionDigits, getCurrencyMinorUnitFactor } from '../countries'; -import { getTenantCurrency, syncUnpaidBillsForOrder } from './bills'; +import { + checkKitchenDeliveryStatus, + completeExactBalanceSettlement, + getTenantCurrency, + syncUnpaidBillsForOrder, +} from './bills'; import expressRateLimit from 'express-rate-limit'; import { randomUUID } from 'crypto'; import { isAllowedPrivateIp } from '../middleware/security'; @@ -3099,7 +3104,16 @@ router.patch( ); } - const { discount_type, discount_value, discount_reason } = req.body || {}; + const { + discount_type, + discount_value, + discount_reason, + override_pin, + approver_id, + user_id, + manager_id, + } = req.body || {}; + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; // Validate discount_type if ( @@ -3146,21 +3160,18 @@ router.patch( if (discount_value > 0) { const requiresApproval = getSettingValue('discount_requires_approval') === 'true'; if (requiresApproval) { - const { override_pin } = req.body || {}; if (!override_pin) { return res .status(403) .json({ error: 'Manager PIN required for discounts', requiresApproval: true }); } - const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; const rateLimitKey = `pin:${clientIp}:discount`; if (isPinRateLimited(rateLimitKey)) { return res .status(429) .json({ error: 'Too many PIN attempts. Try again in 15 minutes.' }); } - const explicitApproverId = - req.body.approver_id || req.body.manager_id || req.body.user_id; + const explicitApproverId = approver_id || manager_id || user_id; const candidateUsers = explicitApproverId ? (db .prepare( @@ -3414,17 +3425,34 @@ router.patch( pack, currency, ); - if ((existingBill.paid_amount || 0) > 0 && billTotal < existingBill.paid_amount) { + const billPaidAmount = Number(existingBill.paid_amount || 0); + if (billPaidAmount > 0 && billTotal < billPaidAmount) { throw Object.assign( new Error('Discount cannot reduce bill total below collected payments'), { statusCode: 409, diagnosticCode: 'below_collected_funds' }, ); } - const newBillBalance = Math.max(0, billTotal - (existingBill.paid_amount || 0)); + const newBillBalance = Math.max(0, billTotal - billPaidAmount); + const preVerifiedDiscountApprover = + approvedByUserId && preVerifiedOrderDiscountPinHash + ? { id: approvedByUserId, verifiedPinHash: preVerifiedOrderDiscountPinHash } + : null; + const kitchenStatus = checkKitchenDeliveryStatus( + db, + String(req.params.id), + override_pin, + clientIp, + existingBill.id, + preVerifiedDiscountApprover, + ); + const isBillNowPaid = billPaidAmount > 0 && newBillBalance === 0 && kitchenStatus.allowed; db.prepare(` UPDATE bills SET subtotal = ?, discount_amount = ?, discount_type = ?, discount_value = ?, discount_reason = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, total = ?, balance = ?, - service_charge = ?, packaging_charge = ?, charges_breakdown = ?, round_off = ?, updated_at = ? + service_charge = ?, packaging_charge = ?, charges_breakdown = ?, round_off = ?, + payment_status = CASE WHEN ? = 1 THEN 'paid' ELSE payment_status END, + paid_at = CASE WHEN ? = 1 THEN COALESCE(paid_at, ?) ELSE paid_at END, + updated_at = ? WHERE id = ? `).run( totals.subtotal, @@ -3441,9 +3469,35 @@ router.patch( syncedPackagingCharge, syncedChargesJson, billRoundOff, + isBillNowPaid ? 1 : 0, + isBillNowPaid ? 1 : 0, + now(), now(), existingBill.id, ); + if (isBillNowPaid) { + completeExactBalanceSettlement( + db, + existingBill.id, + billTotal, + currency, + now(), + override_pin, + clientIp, + preVerifiedDiscountApprover, + ); + } + if (kitchenStatus.overridden && isBillNowPaid) { + recordOrderAudit(db, { + orderId: req.params.id as string, + actorUserId: (req as any).user.userId, + action: 'kitchen_delivery_override', + details: { + manager_user_id: kitchenStatus.managerUserId, + undelivered_count: kitchenStatus.count, + }, + }); + } } recordOrderAudit(db, { @@ -3540,7 +3594,8 @@ router.patch( .json({ error: 'Cannot apply discount to a cancelled, voided, or refunded item' }); } - const { discount_type, discount_value } = req.body; + const { discount_type, discount_value, override_pin, approver_id, user_id } = req.body; + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; // Validate discount_type if (!discount_type || !['percentage', 'amount'].includes(discount_type)) { @@ -3562,18 +3617,16 @@ router.patch( let preVerifiedItemDiscountPinHash: string | undefined; const requiresApproval = getSettingValue('discount_requires_approval') === 'true'; if (requiresApproval) { - const { override_pin } = req.body; if (!override_pin) { return res .status(403) .json({ error: 'Manager PIN required for discounts', requiresApproval: true }); } - const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; const rateLimitKey = `pin:${clientIp}:item-discount`; if (isPinRateLimited(rateLimitKey)) { return res.status(429).json({ error: 'Too many PIN attempts. Try again in 15 minutes.' }); } - const explicitApproverId = req.body.approver_id || req.body.manager_id || req.body.user_id; + const explicitApproverId = approver_id || req.body.manager_id || user_id; const candidateUsers = explicitApproverId ? (db .prepare( @@ -3850,15 +3903,32 @@ router.patch( pack, currency, ); - if ((existingBill.paid_amount || 0) > 0 && billTotal < existingBill.paid_amount) { + const billPaidAmount = Number(existingBill.paid_amount || 0); + if (billPaidAmount > 0 && billTotal < billPaidAmount) { throw Object.assign( new Error('Discount cannot reduce bill total below collected payments'), { statusCode: 409, diagnosticCode: 'below_collected_funds' }, ); } - const newBillBalance = Math.max(0, billTotal - (existingBill.paid_amount || 0)); + const newBillBalance = Math.max(0, billTotal - billPaidAmount); + const preVerifiedDiscountApprover = + approvedByUserId && preVerifiedItemDiscountPinHash + ? { id: approvedByUserId, verifiedPinHash: preVerifiedItemDiscountPinHash } + : null; + const kitchenStatus = checkKitchenDeliveryStatus( + db, + String(req.params.id), + override_pin, + clientIp, + existingBill.id, + preVerifiedDiscountApprover, + ); + const isBillNowPaid = billPaidAmount > 0 && newBillBalance === 0 && kitchenStatus.allowed; db.prepare( - `UPDATE bills SET subtotal = ?, total = ?, balance = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, packaging_charge = ?, charges_breakdown = ?, round_off = ?, updated_at = ? WHERE id = ?`, + `UPDATE bills SET subtotal = ?, total = ?, balance = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, packaging_charge = ?, charges_breakdown = ?, round_off = ?, + payment_status = CASE WHEN ? = 1 THEN 'paid' ELSE payment_status END, + paid_at = CASE WHEN ? = 1 THEN COALESCE(paid_at, ?) ELSE paid_at END, + updated_at = ? WHERE id = ?`, ).run( orderTotals.subtotal, billTotal, @@ -3871,9 +3941,36 @@ router.patch( syncedPackagingCharge, syncedChargesJson, billRoundOff, + isBillNowPaid ? 1 : 0, + isBillNowPaid ? 1 : 0, + now(), now(), existingBill.id, ); + if (isBillNowPaid) { + completeExactBalanceSettlement( + db, + existingBill.id, + billTotal, + currency, + now(), + override_pin, + clientIp, + preVerifiedDiscountApprover, + ); + } + if (kitchenStatus.overridden && isBillNowPaid) { + recordOrderAudit(db, { + orderId: req.params.id as string, + orderItemId: req.params.itemId as string, + actorUserId: (req as any).user.userId, + action: 'kitchen_delivery_override', + details: { + manager_user_id: kitchenStatus.managerUserId, + undelivered_count: kitchenStatus.count, + }, + }); + } } recordOrderAudit(db, { diff --git a/package.json b/package.json index b7bc021af..2a0bc2591 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "pretest": "npm run test:script-coverage && bash tests/run-test.sh npm run test:expenses && bash tests/run-test.sh npm run test:payment-methods-split && bash tests/run-test.sh npm run test:release-regressions && bash tests/run-test.sh npm run test:diagnostics && bash tests/run-test.sh npm run test:reports-export && bash tests/run-test.sh npm run test:category-addon-groups && bash tests/run-test.sh npm run test:product-variants-db && bash tests/run-test.sh npm run test:addon-inventory-db && bash tests/run-test.sh npm run test:addon-inventory-api && bash tests/run-test.sh npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", - "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:update-ipc-sender-validation && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:pos-keyboard-shortcuts && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:order-item-edit && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:desktop-trailing-slash-routing && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:customer-top-items && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:cloud-live-orders-batching && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-order-hold-payment && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:financial-safety-remediation && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:customer-display-config && bash tests/run-test.sh npm run test:customer-display-settings-route && bash tests/run-test.sh npm run test:customer-display-pairing && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle && bash tests/run-test.sh npm run test:lint-engine && bash tests/run-test.sh npm run test:oxc-contributor-checks", + "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:update-ipc-sender-validation && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:pos-keyboard-shortcuts && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:order-item-edit && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:desktop-trailing-slash-routing && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:customer-top-items && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:cloud-live-orders-batching && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-order-hold-payment && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-exact-balance-settlement && bash tests/run-test.sh npm run test:financial-safety-remediation && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:customer-display-config && bash tests/run-test.sh npm run test:customer-display-settings-route && bash tests/run-test.sh npm run test:customer-display-pairing && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle && bash tests/run-test.sh npm run test:lint-engine && bash tests/run-test.sh npm run test:oxc-contributor-checks", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:lint-engine": "ts-node --transpile-only -P tests/tsconfig.json tests/lint-engine-parity.test.ts", @@ -184,6 +184,7 @@ "test:issue-24": "node tests/run-electron-node-test.cjs tests/issue-24-cancel-item-checkout.test.ts", "test:issue-zero-balance-settlement": "node tests/run-electron-node-test.cjs tests/issue-zero-balance-settlement.test.ts", "test:issue-terminal-state-guards": "node tests/run-electron-node-test.cjs tests/issue-terminal-state-guards.test.ts", + "test:issue-exact-balance-settlement": "node tests/run-electron-node-test.cjs tests/issue-exact-balance-settlement.test.ts", "test:financial-safety-remediation": "node tests/run-electron-node-test.cjs tests/financial-safety-remediation.test.ts", "test:issue-payload-and-discount-bounds": "node tests/run-electron-node-test.cjs tests/issue-payload-and-discount-bounds.test.ts", "test:issue-134-routing": "node tests/run-electron-node-test.cjs tests/issue-134-station-routing.test.ts", diff --git a/tests/issue-exact-balance-settlement.test.ts b/tests/issue-exact-balance-settlement.test.ts new file mode 100644 index 000000000..f9bad177d --- /dev/null +++ b/tests/issue-exact-balance-settlement.test.ts @@ -0,0 +1,949 @@ +/** + * Issue #1176 / PR #1192: Automatic Exact-Balance Settlement + * Verifies automatic completion of settlement when an order, item, or bill discount + * reduces the remaining balance to exactly 0 (or when paid_amount equals the newly discounted total): + * - Bill payment_status = 'paid', balance = 0, paid_at timestamp recorded. + * - Order status = 'completed', completed_at timestamp recorded, dine-in table freed. + * - Loyalty points credited on collected funds. + * - Strictly NO fabricated payment lines or phantom payment entries. + * - below_collected_funds guard (409) remains intact. + * - Cash session and daily report accounting remain accurate. + * - Payment reversals correctly affect net collected funds. + * + * Run: node tests/run-electron-node-test.cjs tests/issue-exact-balance-settlement.test.ts + */ + +// ── Electron Mock (must be before any app imports) ─────────────────────────── +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-exact-balance-settlement-')); +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + return originalLoad.apply(this, arguments as any); +}; + +const { + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + seedTable, + api, + assert, + assertEqual, + getResults, + closeDatabase, + getDatabase, + now, +} = require('./helpers/test-setup'); + +const { orderRoutes } = require('../main/routes/orders'); +const { billRoutes } = require('../main/routes/bills'); +const { refundRoutes } = require('../main/routes/refunds'); +const { registerRoutes } = require('../main/routes/index'); + +function setSetting(db: any, key: string, value: string): void { + db.prepare( + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + ).run(key, value, now()); +} + +function billRow(db: any, id: any): any { + return db + .prepare( + `SELECT id, bill_number, payment_status, subtotal, discount_amount, discount_type, discount_value, total, paid_amount, balance, payment_details, paid_at, customer_id, order_id + FROM bills WHERE id = ?`, + ) + .get(id); +} + +function orderRow(db: any, id: any): any { + return db + .prepare( + `SELECT id, status, table_id, subtotal, discount_amount, total, completed_at, customer_id FROM orders WHERE id = ?`, + ) + .get(id); +} + +function tableRow(db: any, id: any): any { + return db.prepare(`SELECT id, status FROM tables WHERE id = ?`).get(id); +} + +async function main(): Promise { + console.log('Issue #1176: Automatic Exact-Balance Settlement Regression Suite'); + const db = initTestDb(); + + setSetting(db, 'discount_mode', 'both'); + setSetting(db, 'discount_max_amount', '0'); + setSetting(db, 'discount_max_percentage', '0'); + setSetting(db, 'discount_requires_approval', 'false'); + + const { authHeader: ownerAuth } = seedOwnerUser(db); + const { userId: managerId } = seedManagerUser(db); + seedCategory(db, 'cat-eb', 'EB menu'); + seedProduct(db, 'prod-1000', 'cat-eb', 'One Thousand', 1000, { cb_percent: null }); + seedProduct(db, 'prod-600', 'cat-eb', 'Six Hundred', 600, { cb_percent: null }); + seedProduct(db, 'prod-400', 'cat-eb', 'Four Hundred', 400, { cb_percent: null }); + seedTable(db, 'table-eb-1', 1); + seedTable(db, 'table-eb-2', 2); + + const app = createApp({ + '/api/orders': orderRoutes, + '/api/bills': billRoutes, + '/api/refunds': refundRoutes, + }); + registerRoutes(app); + const { baseUrl, server } = await startServer(app); + + const A = ownerAuth; + + async function createOrder(body: any) { + return api(baseUrl, '/api/orders', { method: 'POST', body, headers: A }); + } + async function generateBill(orderId: any) { + return api(baseUrl, '/api/bills/generate', { + method: 'POST', + body: { order_id: orderId }, + headers: A, + }); + } + async function pay(billId: any, body: any) { + return api(baseUrl, `/api/bills/${billId}/payment`, { method: 'POST', body, headers: A }); + } + async function applyBillDiscount(billId: any, body: any) { + return api(baseUrl, `/api/bills/${billId}/applyDiscount`, { method: 'POST', body, headers: A }); + } + async function applyOrderDiscount(orderId: any, body: any) { + return api(baseUrl, `/api/orders/${orderId}/discount`, { method: 'PATCH', body, headers: A }); + } + async function itemDiscount(orderId: any, itemId: any, body: any) { + return api(baseUrl, `/api/orders/${orderId}/items/${itemId}/discount`, { + method: 'PATCH', + body, + headers: A, + }); + } + async function refund(body: any) { + return api(baseUrl, '/api/refunds', { method: 'POST', body, headers: A }); + } + + try { + // ── 1. Bill Discount Flow: Exact balance reduction auto-settles bill & completes order ── + console.log( + '\n─── 1. Bill Discount Flow: Exact-balance discount auto-settles bill & completes dine-in order ───', + ); + { + const create = await createOrder({ + type: 'dine_in', + table_id: 'table-eb-1', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + assertEqual(create.status, 201, '1. bill-discount: dine-in order created'); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + assertEqual(gen.status, 201, '1. bill-discount: bill generated'); + const billId = gen.data.bill.id; + + // Partial payment of 600 cash leaves balance 400 + const payRes = await pay(billId, { method: 'cash', amount: 600 }); + assertEqual(payRes.status, 200, '1. bill-discount: partial payment of 600 accepted'); + const billBeforeDisc = billRow(db, billId); + assertEqual( + billBeforeDisc.payment_status, + 'partial', + '1. bill-discount: status is partial before discount', + ); + assertEqual(Number(billBeforeDisc.balance), 400, '1. bill-discount: balance is 400'); + + // Apply bill discount of 400 — exact balance reduction + const discRes = await applyBillDiscount(billId, { + type: 'amount', + value: 400, + reason: 'EB bill test', + }); + assertEqual(discRes.status, 200, '1. bill-discount: discount applied successfully'); + assertEqual( + discRes.data.bill.payment_status, + 'paid', + '1. bill-discount: response reports paid status', + ); + assertEqual( + Number(discRes.data.bill.balance), + 0, + '1. bill-discount: response reports balance 0', + ); + + // Verify persisted state in DB + const billAfterDisc = billRow(db, billId); + assertEqual( + billAfterDisc.payment_status, + 'paid', + '1. bill-discount: bill payment_status auto-settled to paid', + ); + assertEqual(Number(billAfterDisc.total), 600, '1. bill-discount: bill total reduced to 600'); + assertEqual( + Number(billAfterDisc.paid_amount), + 600, + '1. bill-discount: bill paid_amount remains 600', + ); + assertEqual(Number(billAfterDisc.balance), 0, '1. bill-discount: bill balance is 0'); + assert(Boolean(billAfterDisc.paid_at), '1. bill-discount: paid_at timestamp is populated'); + + // Invariant: NEVER fabricate extra payment lines + const payments = JSON.parse(billAfterDisc.payment_details || '[]'); + assertEqual( + payments.length, + 1, + '1. bill-discount: exactly 1 payment record preserved, NO phantom entries', + ); + assertEqual( + Number(payments[0].amount), + 600, + '1. bill-discount: payment record holds original 600 tender', + ); + + // Verify order completed and dine-in table freed + const ordAfterDisc = orderRow(db, orderId); + assertEqual( + ordAfterDisc.status, + 'completed', + '1. bill-discount: order status transitioned to completed', + ); + assert( + Boolean(ordAfterDisc.completed_at), + '1. bill-discount: order completed_at timestamp populated', + ); + assertEqual( + tableRow(db, 'table-eb-1')?.status, + 'available', + '1. bill-discount: table freed to available', + ); + + // Verify idempotent retry / zero-balance payment call succeeds cleanly + const settleRetry = await pay(billId, { method: 'cash', amount: null }); + assertEqual( + settleRetry.status, + 200, + '1. bill-discount: settlement retry on settled bill returns 200', + ); + assertEqual( + settleRetry.data.bill.payment_status, + 'paid', + '1. bill-discount: retry preserves paid status', + ); + } + + // ── 2. Order Discount Flow: Exact balance reduction auto-settles bill & completes order ── + console.log( + '\n─── 2. Order Discount Flow: Order discount auto-settles bill & completes order ───', + ); + { + const create = await createOrder({ + type: 'dine_in', + table_id: 'table-eb-2', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + assertEqual(create.status, 201, '2. order-discount: dine-in order created'); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + assertEqual(gen.status, 201, '2. order-discount: bill generated'); + const billId = gen.data.bill.id; + + // Partial payment of 400 cash leaves balance 600 + const payRes = await pay(billId, { method: 'cash', amount: 400 }); + assertEqual(payRes.status, 200, '2. order-discount: partial payment of 400 accepted'); + + // Apply order discount of 600 + const discRes = await applyOrderDiscount(orderId, { + discount_type: 'amount', + discount_value: 600, + }); + assertEqual(discRes.status, 200, '2. order-discount: order discount applied successfully'); + assertEqual( + discRes.data.order.status, + 'completed', + '2. order-discount: order response status is completed', + ); + + // Verify persisted state + const billAfterDisc = billRow(db, billId); + assertEqual( + billAfterDisc.payment_status, + 'paid', + '2. order-discount: bill payment_status auto-settled to paid', + ); + assertEqual(Number(billAfterDisc.total), 400, '2. order-discount: bill total reduced to 400'); + assertEqual( + Number(billAfterDisc.paid_amount), + 400, + '2. order-discount: bill paid_amount remains 400', + ); + assertEqual(Number(billAfterDisc.balance), 0, '2. order-discount: bill balance is 0'); + assert(Boolean(billAfterDisc.paid_at), '2. order-discount: paid_at timestamp is populated'); + + const payments = JSON.parse(billAfterDisc.payment_details || '[]'); + assertEqual(payments.length, 1, '2. order-discount: no phantom payment lines fabricated'); + + const ordAfterDisc = orderRow(db, orderId); + assertEqual(ordAfterDisc.status, 'completed', '2. order-discount: order status is completed'); + assertEqual( + tableRow(db, 'table-eb-2')?.status, + 'available', + '2. order-discount: table freed to available', + ); + } + + // ── 3. Item Discount Flow: Item discount zeroes balance and auto-settles ── + console.log( + '\n─── 3. Item Discount Flow: Item discount auto-settles bill & completes order ───', + ); + { + const create = await createOrder({ + type: 'takeaway', + items: [ + { product_id: 'prod-600', quantity: 1 }, + { product_id: 'prod-400', quantity: 1 }, + ], + }); + assertEqual(create.status, 201, '3. item-discount: takeaway order created'); + const orderId = create.data.order.id; + const itemId400 = create.data.order.items.find((i: any) => i.product_id === 'prod-400').id; + const gen = await generateBill(orderId); + assertEqual(gen.status, 201, '3. item-discount: bill generated (total 1000)'); + const billId = gen.data.bill.id; + + // Partial payment of 600 cash covers the prod-600 item, balance 400 + const payRes = await pay(billId, { method: 'cash', amount: 600 }); + assertEqual(payRes.status, 200, '3. item-discount: partial payment of 600 accepted'); + + // Apply 100% item discount (400) on prod-400 + const discRes = await itemDiscount(orderId, itemId400, { + discount_type: 'amount', + discount_value: 400, + }); + assertEqual(discRes.status, 200, '3. item-discount: item discount applied'); + + const billAfterDisc = billRow(db, billId); + assertEqual( + billAfterDisc.payment_status, + 'paid', + '3. item-discount: bill auto-settled to paid', + ); + assertEqual(Number(billAfterDisc.total), 600, '3. item-discount: bill total reduced to 600'); + assertEqual( + Number(billAfterDisc.paid_amount), + 600, + '3. item-discount: bill paid_amount remains 600', + ); + assertEqual(Number(billAfterDisc.balance), 0, '3. item-discount: bill balance is 0'); + assert(Boolean(billAfterDisc.paid_at), '3. item-discount: paid_at timestamp populated'); + + const ordAfterDisc = orderRow(db, orderId); + assertEqual(ordAfterDisc.status, 'completed', '3. item-discount: order status is completed'); + } + + // ── 4. Percentage Discount Flow: Percentage discount zeroes balance ── + console.log( + '\n─── 4. Percentage Discount Flow: 50% discount on 1000 bill with 500 paid auto-settles ───', + ); + { + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + const billId = gen.data.bill.id; + + await pay(billId, { method: 'cash', amount: 500 }); + const discRes = await applyOrderDiscount(orderId, { + discount_type: 'percentage', + discount_value: 50, + }); + assertEqual(discRes.status, 200, '4. pct-discount: 50% discount applied'); + + const billAfterDisc = billRow(db, billId); + assertEqual( + billAfterDisc.payment_status, + 'paid', + '4. pct-discount: bill auto-settled to paid', + ); + assertEqual(Number(billAfterDisc.total), 500, '4. pct-discount: bill total is 500'); + assertEqual(Number(billAfterDisc.balance), 0, '4. pct-discount: bill balance is 0'); + assertEqual(orderRow(db, orderId).status, 'completed', '4. pct-discount: order completed'); + } + + // ── 5. below_collected_funds Guard (409) Remains Intact ── + console.log('\n─── 5. below_collected_funds Guard (409) Remains Intact Across All Routes ───'); + { + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + const billId = gen.data.bill.id; + + // Pay 600 + await pay(billId, { method: 'cash', amount: 600 }); + + // Try bill discount of 500 (total would become 500 < 600 paid) -> MUST 409 + const rejBill = await applyBillDiscount(billId, { type: 'amount', value: 500 }); + assertEqual(rejBill.status, 409, '5. guard: bill discount below collected rejected with 409'); + assertEqual( + rejBill.data.display_code, + 'below_collected_funds', + '5. guard: display code below_collected_funds', + ); + + // Try order discount of 500 -> MUST 409 + const rejOrder = await applyOrderDiscount(orderId, { + discount_type: 'amount', + discount_value: 500, + }); + assertEqual( + rejOrder.status, + 409, + '5. guard: order discount below collected rejected with 409', + ); + assertEqual( + rejOrder.data.display_code, + 'below_collected_funds', + '5. guard: display code below_collected_funds', + ); + + // Verify bill state remains partial, balance 400 + const currentBill = billRow(db, billId); + assertEqual(currentBill.payment_status, 'partial', '5. guard: bill remains partial'); + assertEqual(Number(currentBill.total), 1000, '5. guard: total untouched'); + assertEqual(Number(currentBill.paid_amount), 600, '5. guard: paid_amount untouched'); + assertEqual(Number(currentBill.balance), 400, '5. guard: balance untouched'); + } + + // ── 6. Zero-Paid Bill with 100% Discount Stays Unpaid ── + console.log('\n─── 6. Zero-Paid Bill Stays Unpaid (No False Auto-Settlement) ───'); + { + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + const billId = gen.data.bill.id; + + // 100% discount with 0 collected funds + const disc = await applyOrderDiscount(orderId, { + discount_type: 'amount', + discount_value: 1000, + }); + assertEqual(disc.status, 200, '6. zero-paid: full discount applied'); + + const bill = billRow(db, billId); + assertEqual( + bill.payment_status, + 'unpaid', + '6. zero-paid: bill remains unpaid when paid_amount is 0', + ); + assertEqual(Number(bill.total), 0, '6. zero-paid: bill total is 0'); + assertEqual(Number(bill.paid_amount), 0, '6. zero-paid: paid_amount is 0'); + assertEqual(Number(bill.balance), 0, '6. zero-paid: balance is 0'); + assertEqual( + orderRow(db, orderId).status, + 'pending', + '6. zero-paid: order remains pending until settled', + ); + } + + // ── 7. Loyalty Points Credited on Collected Funds ── + console.log('\n─── 7. Loyalty Points Credited on Collected Funds ───'); + { + setSetting(db, 'loyalty_enabled', 'true'); + setSetting(db, 'global_cashback_percent', '10'); + + // Seed customer + const custId = 'cust-eb-loyalty'; + db.prepare( + `INSERT INTO customers (id, name, phone, created_at, updated_at) VALUES (?, 'Loyal Guest', '5551234', ?, ?)`, + ).run(custId, now(), now()); + + const create = await createOrder({ + type: 'takeaway', + customer_id: custId, + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + const billId = gen.data.bill.id; + + // Partial payment of 600 cash + await pay(billId, { method: 'cash', amount: 600 }); + + // Apply 400 discount -> auto-settles + await applyBillDiscount(billId, { type: 'amount', value: 400 }); + + const bill = billRow(db, billId); + assertEqual(bill.payment_status, 'paid', '7. loyalty: bill auto-settled'); + + // Loyalty points should be calculated on the discounted/collected subtotal (600 @ 10% = 60 points) + const ledgerEntry = db + .prepare(`SELECT * FROM loyalty_ledger WHERE bill_id = ? AND type = 'credit'`) + .get(billId) as any; + assert(Boolean(ledgerEntry), '7. loyalty: loyalty credit entry created'); + assertEqual( + Number(ledgerEntry.amount), + 60, + '7. loyalty: cashback credited on collected funds (60 points)', + ); + } + + // ── 8. Payment Reversals / Refunds on Auto-Settled Bills ── + console.log('\n─── 8. Payment Reversals Correctly Affect Net Collected Funds ───'); + { + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + const billId = gen.data.bill.id; + + await pay(billId, { method: 'cash', amount: 600 }); + await applyBillDiscount(billId, { type: 'amount', value: 400 }); + assertEqual(billRow(db, billId).payment_status, 'paid', '8. refund: bill auto-settled'); + + // Partial refund of 200 cash + const ref1 = await refund({ + bill_id: billId, + amount: 200, + method: 'cash', + reason: 'EB partial refund', + override_pin: '1234', + approver_id: managerId, + }); + assertEqual(ref1.status, 201, '8. refund: partial refund of 200 created'); + assertEqual( + billRow(db, billId).payment_status, + 'partially_refunded', + '8. refund: status transitions to partially_refunded', + ); + + // Full remaining refund of 400 cash + const ref2 = await refund({ + bill_id: billId, + amount: 400, + method: 'cash', + reason: 'EB remaining refund', + override_pin: '1234', + approver_id: managerId, + }); + assertEqual(ref2.status, 201, '8. refund: final refund of 400 created'); + assertEqual( + billRow(db, billId).payment_status, + 'refunded', + '8. refund: status transitions to refunded', + ); + + // Subsequent payment attempt on refunded bill is rejected + const payAfterRef = await pay(billId, { method: 'cash', amount: 100 }); + assertEqual(payAfterRef.status, 409, '8. refund: payment on refunded bill rejected with 409'); + } + + // ── 9. Payment Rejection on Paid Bills vs Zero-Balance Retry & Cash Session Accounting ── + console.log( + '\n─── 9. Positive Payment Rejected on Settled Bill & Accurate Cash Accounting ───', + ); + { + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId = create.data.order.id; + const gen = await generateBill(orderId); + const billId = gen.data.bill.id; + + // Pay 600 cash + const initialPay = await pay(billId, { method: 'cash', amount: 600 }); + assertEqual(initialPay.status, 200, '9. cash: partial payment of 600 accepted'); + + // Auto-settle via 400 discount + const disc = await applyBillDiscount(billId, { type: 'amount', value: 400 }); + assertEqual(disc.status, 200, '9. cash: discount applied and auto-settled'); + assertEqual(billRow(db, billId).payment_status, 'paid', '9. cash: bill is paid'); + + // Attempting positive card payment on already-settled bill MUST reject with bill_already_paid + const extraPay = await pay(billId, { method: 'card', amount: 100 }); + assertEqual( + extraPay.status, + 400, + '9. pay-rejected: positive payment on paid bill rejected with 400', + ); + assertEqual( + extraPay.data.display_code, + 'bill_already_paid', + '9. pay-rejected: display code is bill_already_paid', + ); + + // Attempting positive cash payment on already-settled bill MUST ALSO reject with bill_already_paid (preventing phantom/unrecorded cash tender) + const extraCashPay = await pay(billId, { method: 'cash', amount: 50 }); + assertEqual( + extraCashPay.status, + 400, + '9. pay-rejected: positive cash payment on paid bill rejected with 400', + ); + assertEqual( + extraCashPay.data.display_code, + 'bill_already_paid', + '9. pay-rejected: cash display code is bill_already_paid', + ); + + // Retrying single-line zero-balance settle (amount: null) succeeds cleanly + const zeroRetry = await pay(billId, { method: 'cash', amount: null }); + assertEqual( + zeroRetry.status, + 200, + '9. retry: zero-balance omitted-amount settle retry succeeds', + ); + assertEqual(zeroRetry.data.bill.payment_status, 'paid', '9. retry: bill remains paid'); + + // Payment details must strictly reflect ONLY the actual 600 tendered cash, never phantom entries + const recordedPayments = JSON.parse(billRow(db, billId).payment_details || '[]'); + assertEqual( + recordedPayments.length, + 1, + '9. accounting: exactly one payment record preserved in ledger', + ); + assertEqual( + Number(recordedPayments[0].amount), + 600, + '9. accounting: ledger entry has exact 600 tendered cash', + ); + } + + // ── 10. Sibling Bill Topology: Exact-Balance Auto-Settlement with Unpaid Sibling ── + console.log('\n─── 10. Sibling Bill Topology with Unpaid Sibling ───'); + { + const create = await createOrder({ + type: 'dine_in', + table_id: 'table-eb-2', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + assertEqual(create.status, 201, '10. sibling: dine-in order created'); + const orderId = create.data.order.id; + + const gen = await generateBill(orderId); + assertEqual(gen.status, 201, '10. sibling: bill 1 generated'); + const bill1Id = gen.data.bill.id; + + // Seed an unpaid sibling bill for the same order + const siblingRes = db + .prepare( + `INSERT INTO bills (order_id, bill_number, subtotal, total, balance, paid_amount, payment_status, created_at, updated_at) + VALUES (?, 'BILL-SIB-10', 500, 500, 500, 0, 'unpaid', ?, ?)`, + ) + .run(orderId, now(), now()); + const siblingBillId = siblingRes.lastInsertRowid; + + // Partially pay Bill 1: pay 600 out of 1000 + const pay1 = await pay(bill1Id, { method: 'cash', amount: 600 }); + assertEqual(pay1.status, 200, '10. sibling: bill 1 partial pay 600 accepted'); + + // Discount Bill 1 by 400 -> exact-balance auto-settles Bill 1 + const disc1 = await applyBillDiscount(bill1Id, { type: 'amount', value: 400 }); + assertEqual(disc1.status, 200, '10. sibling: bill 1 discounted by 400'); + assertEqual( + billRow(db, bill1Id).payment_status, + 'paid', + '10. sibling: bill 1 auto-settled to paid', + ); + assertEqual(Number(billRow(db, bill1Id).balance), 0, '10. sibling: bill 1 balance is 0'); + + // Crucial: Sibling bill is still unpaid, so Order must NOT be completed and table must NOT be freed! + assertEqual( + billRow(db, siblingBillId).payment_status, + 'unpaid', + '10. sibling: sibling bill remains unpaid', + ); + assertEqual( + orderRow(db, orderId).status !== 'completed', + true, + '10. sibling: order remains active because sibling bill is unpaid', + ); + assertEqual( + tableRow(db, 'table-eb-2').status, + 'occupied', + '10. sibling: table remains occupied', + ); + + // Now pay sibling bill in full + const paySib = await pay(siblingBillId, { method: 'cash', amount: 500 }); + assertEqual(paySib.status, 200, '10. sibling: sibling bill paid in full'); + assertEqual( + billRow(db, siblingBillId).payment_status, + 'paid', + '10. sibling: sibling bill is paid', + ); + + // Now all sibling bills are paid -> order completes and table is released! + assertEqual( + orderRow(db, orderId).status, + 'completed', + '10. sibling: order transitions to completed after all bills paid', + ); + assertEqual( + tableRow(db, 'table-eb-2').status, + 'available', + '10. sibling: table released to available', + ); + } + + // ── 11. Kitchen Delivery Gate on Exact-Balance Settlement & Zero-Balance Retries ── + console.log( + '\n─── 11. Kitchen Delivery Gate on Exact-Balance Settlement & Zero-Balance Retries ───', + ); + { + setSetting(db, 'require_kitchen_delivered_before_settlement', 'false'); + setSetting(db, 'kds_enabled', 'true'); + setSetting(db, 'billing_type', 'postpaid'); + + // Subtest 11a: Bill discount reduces balance to 0 while kitchen items pending (no override) + const create11a = await createOrder({ + type: 'dine_in', + table_id: 'table-eb-1', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + assertEqual(create11a.status, 201, '11a. dine-in order created'); + const orderId11a = create11a.data.order.id; + + const gen11a = await generateBill(orderId11a); + const billId11a = gen11a.data.bill.id; + + // Pay 600 cash while items not yet in kitchen + const pay11a = await pay(billId11a, { method: 'cash', amount: 600 }); + assertEqual(pay11a.status, 200, '11a. pay 600 accepted'); + + // Now enable kitchen delivery requirement and mark order item as preparing + setSetting(db, 'require_kitchen_delivered_before_settlement', 'true'); + db.prepare("UPDATE order_items SET status = 'preparing' WHERE order_id = ?").run(orderId11a); + + // Discount bill by 400 without override PIN -> balance becomes 0, but bill MUST NOT settle to paid! + const disc11a = await applyBillDiscount(billId11a, { type: 'amount', value: 400 }); + assertEqual(disc11a.status, 200, '11a. bill discount applied'); + const bill11aAfterDisc = billRow(db, billId11a); + assertEqual( + Number(bill11aAfterDisc.balance), + 0, + '11a. bill balance is 0 after exact-balance discount', + ); + assertEqual( + bill11aAfterDisc.payment_status, + 'partial', + '11a. bill payment_status remains partial due to pending kitchen delivery', + ); + assertEqual( + bill11aAfterDisc.paid_at, + null, + '11a. paid_at remains null due to pending kitchen delivery', + ); + assertEqual( + orderRow(db, orderId11a).status !== 'completed', + true, + '11a. order remains active (not completed)', + ); + assertEqual(tableRow(db, 'table-eb-1').status, 'occupied', '11a. table remains occupied'); + + // Zero-balance payment retry without manager override PIN -> rejected with 409 kitchen_undelivered + const retryNoPin = await pay(billId11a, { method: 'cash', amount: null }); + assertEqual( + retryNoPin.status, + 409, + '11a. zero-balance retry rejected with 409 when kitchen delivery pending', + ); + assertEqual( + retryNoPin.data.display_code, + 'kitchen_undelivered', + '11a. display_code is kitchen_undelivered', + ); + + // Positive payment attempt on 0-balance bill -> rejected with 400 payment_exceeds_balance + const retryPos = await pay(billId11a, { method: 'cash', amount: 50 }); + assertEqual(retryPos.status, 400, '11a. positive tender on 0-balance bill rejected with 400'); + assertEqual( + retryPos.data.display_code, + 'payment_exceeds_balance', + '11a. display_code is payment_exceeds_balance', + ); + + // Zero-balance payment retry with valid manager override PIN -> succeeds! + const retryWithPin = await pay(billId11a, { + method: 'cash', + amount: null, + override_pin: '1234', + approver_id: managerId, + }); + assertEqual( + retryWithPin.status, + 200, + '11a. zero-balance retry with manager PIN override succeeds with 200', + ); + const bill11aFinal = billRow(db, billId11a); + assertEqual( + bill11aFinal.payment_status, + 'paid', + '11a. bill payment_status transitions to paid after override', + ); + assertEqual( + Boolean(bill11aFinal.paid_at), + true, + '11a. paid_at timestamp is set after override', + ); + assertEqual( + orderRow(db, orderId11a).status, + 'completed', + '11a. order transitions to completed after override', + ); + assertEqual( + tableRow(db, 'table-eb-1').status, + 'available', + '11a. table released to available after override', + ); + + // Verify kitchen_delivery_override audit log was recorded + const overrideAudit = db + .prepare( + `SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'kitchen_delivery_override'`, + ) + .get(orderId11a) as any; + assert(Boolean(overrideAudit), '11a. kitchen_delivery_override audit log recorded'); + + // Subtest 11b: Order discount with pending kitchen items but valid manager override_pin + setSetting(db, 'require_kitchen_delivered_before_settlement', 'false'); + const create11b = await createOrder({ + type: 'dine_in', + table_id: 'table-eb-1', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId11b = create11b.data.order.id; + const gen11b = await generateBill(orderId11b); + const billId11b = gen11b.data.bill.id; + + const pay11b = await pay(billId11b, { method: 'cash', amount: 600 }); + assertEqual(pay11b.status, 200, '11b. pay 600 accepted'); + + // Enable kitchen delivery requirement and mark item as preparing + setSetting(db, 'require_kitchen_delivered_before_settlement', 'true'); + db.prepare("UPDATE order_items SET status = 'preparing' WHERE order_id = ?").run(orderId11b); + + // Apply order discount with manager override PIN + const disc11b = await applyOrderDiscount(orderId11b, { + discount_type: 'amount', + discount_value: 400, + override_pin: '1234', + approver_id: managerId, + }); + assertEqual(disc11b.status, 200, '11b. order discount with manager PIN applied'); + const bill11bFinal = billRow(db, billId11b); + assertEqual( + bill11bFinal.payment_status, + 'paid', + '11b. bill auto-settles to paid when manager override PIN supplied in discount', + ); + assertEqual( + orderRow(db, orderId11b).status, + 'completed', + '11b. order completes with manager override', + ); + assertEqual( + tableRow(db, 'table-eb-1').status, + 'available', + '11b. table released to available with manager override', + ); + + // Subtest 11c: Item discount with pending kitchen items -> served transition -> zero-balance retry + setSetting(db, 'require_kitchen_delivered_before_settlement', 'false'); + const create11c = await createOrder({ + type: 'dine_in', + table_id: 'table-eb-1', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); + const orderId11c = create11c.data.order.id; + const itemId11c = create11c.data.order.items[0].id; + const gen11c = await generateBill(orderId11c); + const billId11c = gen11c.data.bill.id; + + const pay11c = await pay(billId11c, { method: 'cash', amount: 600 }); + assertEqual(pay11c.status, 200, '11c. pay 600 accepted'); + + // Enable kitchen delivery requirement and mark item as preparing + setSetting(db, 'require_kitchen_delivered_before_settlement', 'true'); + db.prepare("UPDATE order_items SET status = 'preparing' WHERE order_id = ?").run(orderId11c); + + // Item discount reduces balance to 0 without override PIN + const disc11c = await itemDiscount(orderId11c, itemId11c, { + discount_type: 'amount', + discount_value: 400, + }); + assertEqual(disc11c.status, 200, '11c. item discount applied'); + assertEqual( + billRow(db, billId11c).payment_status, + 'partial', + '11c. bill stays partial because kitchen items are preparing', + ); + + // Mark items as served (kitchen delivery satisfied) + db.prepare("UPDATE order_items SET status = 'served' WHERE order_id = ?").run(orderId11c); + + // Now zero-balance retry succeeds without needing PIN! + const settle11c = await pay(billId11c, { method: 'cash', amount: null }); + assertEqual( + settle11c.status, + 200, + '11c. zero-balance settle succeeds after kitchen items served', + ); + assertEqual( + billRow(db, billId11c).payment_status, + 'paid', + '11c. bill transitions to paid after items served', + ); + assertEqual( + orderRow(db, orderId11c).status, + 'completed', + '11c. order transitions to completed', + ); + assertEqual( + tableRow(db, 'table-eb-1').status, + 'available', + '11c. table released to available', + ); + + // Restore settings + setSetting(db, 'require_kitchen_delivered_before_settlement', 'false'); + } + } finally { + server.close(); + closeDatabase(); + fs.rmSync(testDir, { recursive: true, force: true }); + } + + const { passed, failed, total } = getResults(); + console.log(`\n${'='.repeat(60)}`); + console.log(`Results: ${passed}/${total} passed, ${failed} failed`); + if (failed > 0) { + console.error('FAILED'); + process.exit(1); + } else { + console.log('ALL PASSED'); + } +} + +main().catch((err) => { + console.error('Test crashed:', err); + process.exit(1); +}); diff --git a/tests/issue-terminal-state-guards.test.ts b/tests/issue-terminal-state-guards.test.ts index 0f264f5c6..6d6e93806 100644 --- a/tests/issue-terminal-state-guards.test.ts +++ b/tests/issue-terminal-state-guards.test.ts @@ -693,8 +693,14 @@ async function main(): Promise { assertEqual(Number(afterBill.balance), 0, 'issue-1176: bill balance is 0'); assertEqual( afterBill.payment_status, - 'partial', - 'issue-1176: bill payment_status remains partial until zero-balance settlement', + 'paid', + 'issue-1176: bill payment_status auto-settled to paid', + ); + assertEqual(Boolean(afterBill.paid_at), true, 'issue-1176: paid_at timestamp is populated'); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'issue-1176: order completed after exact-balance discount', ); const settleRes = await pay(billId, { method: 'cash', amount: null }); diff --git a/tests/issue-zero-balance-settlement.test.ts b/tests/issue-zero-balance-settlement.test.ts index 65ca99c38..f5fd376ce 100644 --- a/tests/issue-zero-balance-settlement.test.ts +++ b/tests/issue-zero-balance-settlement.test.ts @@ -12,14 +12,27 @@ const os = require('os'); const path = require('path'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-zero-balance-')); Module._load = function (request: string, parent: unknown, isMain: boolean) { - if (request === 'electron') return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + if (request === 'electron') + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; return originalLoad.apply(this, arguments as any); }; const { - initTestDb, createApp, startServer, - seedOwnerUser, seedManagerUser, seedCategory, seedProduct, seedTable, - api, assert, assertEqual, getResults, closeDatabase, getDatabase, now, + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + seedTable, + api, + assert, + assertEqual, + getResults, + closeDatabase, + getDatabase, + now, } = require('./helpers/test-setup'); const { orderRoutes } = require('../main/routes/orders'); @@ -35,14 +48,20 @@ function setSetting(db: any, key: string, value: string): void { } function billRow(db: any, id: any): any { - return db.prepare( - `SELECT id, payment_status, subtotal, discount_amount, total, paid_amount, balance, order_id + return db + .prepare( + `SELECT id, payment_status, subtotal, discount_amount, total, paid_amount, balance, order_id FROM bills WHERE id = ?`, - ).get(id); + ) + .get(id); } function orderRow(db: any, id: any): any { - return db.prepare(`SELECT id, status, table_id, subtotal, discount_amount, total FROM orders WHERE id = ?`).get(id); + return db + .prepare( + `SELECT id, status, table_id, subtotal, discount_amount, total FROM orders WHERE id = ?`, + ) + .get(id); } function tableRow(db: any, id: any): any { @@ -81,7 +100,11 @@ async function main(): Promise { return api(baseUrl, '/api/orders', { method: 'POST', body, headers: A }); } async function generateBill(orderId: any) { - return api(baseUrl, '/api/bills/generate', { method: 'POST', body: { order_id: orderId }, headers: A }); + return api(baseUrl, '/api/bills/generate', { + method: 'POST', + body: { order_id: orderId }, + headers: A, + }); } async function pay(billId: any, body: any) { return api(baseUrl, `/api/bills/${billId}/payment`, { method: 'POST', body, headers: A }); @@ -93,16 +116,27 @@ async function main(): Promise { return api(baseUrl, `/api/orders/${orderId}/discount`, { method: 'PATCH', body, headers: A }); } async function itemDiscount(orderId: any, itemId: any, body: any) { - return api(baseUrl, `/api/orders/${orderId}/items/${itemId}/discount`, { method: 'PATCH', body, headers: A }); + return api(baseUrl, `/api/orders/${orderId}/items/${itemId}/discount`, { + method: 'PATCH', + body, + headers: A, + }); } async function refund(body: any) { return api(baseUrl, '/api/refunds', { method: 'POST', body, headers: A }); } async function cancelItem(orderId: any, itemId: any, body: any = {}) { - return api(baseUrl, `/api/orders/${orderId}/items/${itemId}/cancel`, { method: 'PATCH', body, headers: A }); + return api(baseUrl, `/api/orders/${orderId}/items/${itemId}/cancel`, { + method: 'PATCH', + body, + headers: A, + }); } async function newTakeawayOrder(productId: string, quantity = 1) { - const create = await createOrder({ type: 'takeaway', items: [{ product_id: productId, quantity }] }); + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: productId, quantity }], + }); assertEqual(create.status, 201, `takeaway order created (${productId})`); const gen = await generateBill(create.data.order.id); assertEqual(gen.status, 201, `bill generated for ${productId}`); @@ -111,7 +145,9 @@ async function main(): Promise { try { // ── order discount zeroes balance, then settle ── - console.log('\n─── order-discount-zero-balance: settle after order discount zeroes balance ───'); + console.log( + '\n─── order-discount-zero-balance: settle after order discount zeroes balance ───', + ); { const create = await createOrder({ type: 'dine_in', @@ -124,16 +160,45 @@ async function main(): Promise { assertEqual(gen.status, 201, 'order-discount-zero-balance: bill generated'); const billId = gen.data.bill.id; await pay(billId, { method: 'cash', amount: 500 }); - const disc = await applyOrderDiscount(orderId, { discount_type: 'amount', discount_value: 500 }); + const disc = await applyOrderDiscount(orderId, { + discount_type: 'amount', + discount_value: 500, + }); assertEqual(disc.status, 200, 'order-discount-zero-balance: order discount applied'); const afterDisc = billRow(db, billId); - assertEqual(Number(afterDisc.balance), 0, 'order-discount-zero-balance: balance is 0 after discount'); + assertEqual( + Number(afterDisc.balance), + 0, + 'order-discount-zero-balance: balance is 0 after discount', + ); + assertEqual( + afterDisc.payment_status, + 'paid', + 'order-discount-zero-balance: bill is auto-settled to paid', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'order-discount-zero-balance: order completed after discount', + ); - const settle = await pay(billId, { method: 'cash', amount: 100 }); - assert(settle.status >= 200 && settle.status < 300, `order-discount-zero-balance: settle not rejected (got ${settle.status} ${JSON.stringify(settle.data)})`); + const settle = await pay(billId, { method: 'cash', amount: null }); + assertEqual( + settle.status, + 200, + 'order-discount-zero-balance: zero-balance settle retry succeeds', + ); const afterSettle = billRow(db, billId); - assertEqual(afterSettle.payment_status, 'paid', 'order-discount-zero-balance: bill is paid after settle'); - assertEqual(orderRow(db, orderId).status, 'completed', 'order-discount-zero-balance: order completed after settle'); + assertEqual( + afterSettle.payment_status, + 'paid', + 'order-discount-zero-balance: bill is paid after settle', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'order-discount-zero-balance: order completed after settle', + ); if (tableRow(db, 'table-zb')?.status !== 'available') { db.prepare(`UPDATE tables SET status = 'available' WHERE id = ?`).run('table-zb'); } @@ -144,22 +209,55 @@ async function main(): Promise { { const { orderId, billId } = await newTakeawayOrder('prod-1000'); await pay(billId, { method: 'cash', amount: 400 }); - const disc = await applyBillDiscount(billId, { type: 'amount', value: 600, reason: 'zb bill-discount-zero-balance' }); + const disc = await applyBillDiscount(billId, { + type: 'amount', + value: 600, + reason: 'zb bill-discount-zero-balance', + }); assertEqual(disc.status, 200, 'bill-discount-zero-balance: bill discount applied'); const afterDisc = billRow(db, billId); - assertEqual(Number(afterDisc.balance), 0, 'bill-discount-zero-balance: balance is 0 after discount'); + assertEqual( + Number(afterDisc.balance), + 0, + 'bill-discount-zero-balance: balance is 0 after discount', + ); + assertEqual( + afterDisc.payment_status, + 'paid', + 'bill-discount-zero-balance: bill is auto-settled to paid', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'bill-discount-zero-balance: order completed after discount', + ); - const settle = await pay(billId, { method: 'cash', amount: 50 }); - assert(settle.status >= 200 && settle.status < 300, `bill-discount-zero-balance: settle not rejected (got ${settle.status} ${JSON.stringify(settle.data)})`); + const settle = await pay(billId, { method: 'cash', amount: null }); + assertEqual( + settle.status, + 200, + 'bill-discount-zero-balance: zero-balance settle retry succeeds', + ); const afterSettle = billRow(db, billId); - assertEqual(afterSettle.payment_status, 'paid', 'bill-discount-zero-balance: bill is paid after settle'); - assertEqual(orderRow(db, orderId).status, 'completed', 'bill-discount-zero-balance: order completed after settle'); + assertEqual( + afterSettle.payment_status, + 'paid', + 'bill-discount-zero-balance: bill is paid after settle', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'bill-discount-zero-balance: order completed after settle', + ); } // ── item discount zeroes balance, then settle ── console.log('\n─── item-discount-zero-balance: settle after item discount zeroes balance ───'); { - const create = await createOrder({ type: 'takeaway', items: [{ product_id: 'prod-1000', quantity: 1 }] }); + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); assertEqual(create.status, 201, 'item-discount-zero-balance: order created'); const orderId = create.data.order.id; const itemId = create.data.order.items[0].id; @@ -167,20 +265,51 @@ async function main(): Promise { assertEqual(gen.status, 201, 'item-discount-zero-balance: bill generated'); const billId = gen.data.bill.id; await pay(billId, { method: 'cash', amount: 500 }); - const disc = await itemDiscount(orderId, itemId, { discount_type: 'amount', discount_value: 500 }); + const disc = await itemDiscount(orderId, itemId, { + discount_type: 'amount', + discount_value: 500, + }); assertEqual(disc.status, 200, 'item-discount-zero-balance: item discount applied'); const afterDisc = billRow(db, billId); - assertEqual(Number(afterDisc.balance), 0, 'item-discount-zero-balance: balance is 0 after discount'); + assertEqual( + Number(afterDisc.balance), + 0, + 'item-discount-zero-balance: balance is 0 after discount', + ); + assertEqual( + afterDisc.payment_status, + 'paid', + 'item-discount-zero-balance: bill is auto-settled to paid', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'item-discount-zero-balance: order completed after discount', + ); - const settle = await pay(billId, { method: 'cash', amount: 100 }); - assert(settle.status >= 200 && settle.status < 300, `item-discount-zero-balance: settle not rejected (got ${settle.status} ${JSON.stringify(settle.data)})`); + const settle = await pay(billId, { method: 'cash', amount: null }); + assertEqual( + settle.status, + 200, + 'item-discount-zero-balance: zero-balance settle retry succeeds', + ); const afterSettle = billRow(db, billId); - assertEqual(afterSettle.payment_status, 'paid', 'item-discount-zero-balance: bill is paid after settle'); - assertEqual(orderRow(db, orderId).status, 'completed', 'item-discount-zero-balance: order completed after settle'); + assertEqual( + afterSettle.payment_status, + 'paid', + 'item-discount-zero-balance: bill is paid after settle', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'item-discount-zero-balance: order completed after settle', + ); } // ── zero-total sibling auto-closes ── - console.log('\n─── zero-total-split-sibling: zero-total sibling auto-closes, order completes ───'); + console.log( + '\n─── zero-total-split-sibling: zero-total sibling auto-closes, order completes ───', + ); { const create = await createOrder({ type: 'dine_in', @@ -211,28 +340,49 @@ async function main(): Promise { const checkA = split.data.bills[0]; const checkB = split.data.bills[1]; - const cancelB = await cancelItem(orderId, itemB.id, { reason: 'zb zero-total-split-sibling' }); + const cancelB = await cancelItem(orderId, itemB.id, { + reason: 'zb zero-total-split-sibling', + }); assertEqual(cancelB.status, 200, 'zero-total-split-sibling: item B cancelled after split'); const bAfter = billRow(db, checkB.id); assertEqual(Number(bAfter.total), 0, 'zero-total-split-sibling: sibling B total is 0'); - assertEqual(bAfter.payment_status, 'paid', 'zero-total-split-sibling: zero-total sibling auto-closed as paid'); + assertEqual( + bAfter.payment_status, + 'paid', + 'zero-total-split-sibling: zero-total sibling auto-closed as paid', + ); const payA = await pay(checkA.id, { method: 'cash', amount: null }); assertEqual(payA.status, 200, 'zero-total-split-sibling: pay A succeeds'); const ordFinal = orderRow(db, orderId); - assertEqual(ordFinal.status, 'completed', 'zero-total-split-sibling: order completed after A paid'); - assert(tableRow(db, 'table-zb')?.status === 'available', 'zero-total-split-sibling: table freed'); + assertEqual( + ordFinal.status, + 'completed', + 'zero-total-split-sibling: order completed after A paid', + ); + assert( + tableRow(db, 'table-zb')?.status === 'available', + 'zero-total-split-sibling: table freed', + ); } // ── kitchen enforcement keeps zero-balance split checks open until delivery ── - console.log('\n─── kitchen-zero-split-settlement: pending zero-balance check waits for delivery ───'); + console.log( + '\n─── kitchen-zero-split-settlement: pending zero-balance check waits for delivery ───', + ); { setSetting(db, 'require_kitchen_delivered_before_settlement', 'true'); setSetting(db, 'require_open_shift', 'true'); setSetting(db, 'kds_enabled', 'true'); setSetting(db, 'discount_mode', 'both'); assertEqual( - Number((db.prepare("SELECT COUNT(*) AS count FROM cash_sessions WHERE status = 'open'").get() as any).count), + Number( + ( + db + .prepare("SELECT COUNT(*) AS count FROM cash_sessions WHERE status = 'open'") + .get() as any + ).count, + ), 0, 'kitchen-zero-split-settlement: fixture has no open cash session', ); @@ -251,47 +401,113 @@ async function main(): Promise { const items = create.data.order.items as any[]; const bill = await generateBill(orderId); assertEqual(bill.status, 201, 'kitchen-zero-split-settlement: bill generated'); - const discount = await applyOrderDiscount(orderId, { discount_type: 'amount', discount_value: 1600 }); - assertEqual(discount.status, 200, 'kitchen-zero-split-settlement: full order discount applied'); + const discount = await applyOrderDiscount(orderId, { + discount_type: 'amount', + discount_value: 1600, + }); + assertEqual( + discount.status, + 200, + 'kitchen-zero-split-settlement: full order discount applied', + ); const split = await api(baseUrl, `/api/bills/${bill.data.bill.id}/split-check`, { method: 'POST', - body: { checks: [ - { label: 'Pending A', items: [{ order_item_id: items[0].id, quantity: 1 }] }, - { label: 'Pending B', items: [{ order_item_id: items[1].id, quantity: 1 }] }, - { label: 'Cancelled C', items: [{ order_item_id: items[2].id, quantity: 1 }] }, - ] }, + body: { + checks: [ + { label: 'Pending A', items: [{ order_item_id: items[0].id, quantity: 1 }] }, + { label: 'Pending B', items: [{ order_item_id: items[1].id, quantity: 1 }] }, + { label: 'Cancelled C', items: [{ order_item_id: items[2].id, quantity: 1 }] }, + ], + }, headers: A, }); assertEqual(split.status, 201, 'kitchen-zero-split-settlement: checks split'); const [checkA, checkB, checkC] = split.data.bills; - const cancelled = await cancelItem(orderId, items[2].id, { reason: 'zb kitchen-zero-split-settlement' }); + const cancelled = await cancelItem(orderId, items[2].id, { + reason: 'zb kitchen-zero-split-settlement', + }); assertEqual(cancelled.status, 200, 'kitchen-zero-split-settlement: sibling item cancelled'); - assertEqual(billRow(db, checkA.id).payment_status, 'unpaid', 'kitchen-zero-split-settlement: pending zero-total check remains unpaid'); - assertEqual(billRow(db, checkB.id).payment_status, 'unpaid', 'kitchen-zero-split-settlement: other pending zero-total check remains unpaid'); - assertEqual(billRow(db, checkC.id).payment_status, 'paid', 'kitchen-zero-split-settlement: cancelled zero-total check can auto-close'); + assertEqual( + billRow(db, checkA.id).payment_status, + 'unpaid', + 'kitchen-zero-split-settlement: pending zero-total check remains unpaid', + ); + assertEqual( + billRow(db, checkB.id).payment_status, + 'unpaid', + 'kitchen-zero-split-settlement: other pending zero-total check remains unpaid', + ); + assertEqual( + billRow(db, checkC.id).payment_status, + 'paid', + 'kitchen-zero-split-settlement: cancelled zero-total check can auto-close', + ); const blocked = await pay(checkA.id, { method: 'cash', amount: null }); - assertEqual(blocked.status, 409, 'kitchen-zero-split-settlement: pending zero-balance check remains blocked'); - assertEqual(blocked.data.code, 'KITCHEN_ITEMS_UNDELIVERED', 'kitchen-zero-split-settlement: kitchen gate runs before shift enforcement'); + assertEqual( + blocked.status, + 409, + 'kitchen-zero-split-settlement: pending zero-balance check remains blocked', + ); + assertEqual( + blocked.data.code, + 'KITCHEN_ITEMS_UNDELIVERED', + 'kitchen-zero-split-settlement: kitchen gate runs before shift enforcement', + ); const override = await pay(checkA.id, { method: 'cash', amount: null, override_pin: pin }); - assertEqual(override.status, 200, 'kitchen-zero-split-settlement: manager can settle pending zero-balance check'); - const overrideAudit = db.prepare("SELECT actor_user_id, details_json FROM order_audit_log WHERE order_id = ? AND action = 'kitchen_delivery_override'").get(orderId) as any; - assertEqual(overrideAudit?.actor_user_id, ownerId, 'kitchen-zero-split-settlement: override audit records the authenticated actor'); - assertEqual(JSON.parse(overrideAudit?.details_json || '{}').manager_user_id, approver, 'kitchen-zero-split-settlement: override audit identifies the manager'); - assert(!JSON.stringify(override.data?.bill?.payment_details || []).includes(pin), 'kitchen-zero-split-settlement: manager PIN is not stored with payment details'); + assertEqual( + override.status, + 200, + 'kitchen-zero-split-settlement: manager can settle pending zero-balance check', + ); + const overrideAudit = db + .prepare( + "SELECT actor_user_id, details_json FROM order_audit_log WHERE order_id = ? AND action = 'kitchen_delivery_override'", + ) + .get(orderId) as any; + assertEqual( + overrideAudit?.actor_user_id, + ownerId, + 'kitchen-zero-split-settlement: override audit records the authenticated actor', + ); + assertEqual( + JSON.parse(overrideAudit?.details_json || '{}').manager_user_id, + approver, + 'kitchen-zero-split-settlement: override audit identifies the manager', + ); + assert( + !JSON.stringify(override.data?.bill?.payment_details || []).includes(pin), + 'kitchen-zero-split-settlement: manager PIN is not stored with payment details', + ); db.prepare("UPDATE order_items SET status = 'served' WHERE id = ?").run(items[1].id); const servedZeroBill = await pay(checkB.id, { method: 'cash', amount: null }); - assertEqual(servedZeroBill.status, 200, 'kitchen-zero-split-settlement: served zero-balance check settles without a PIN'); - assertEqual(Number(billRow(db, checkB.id).paid_amount), 0, 'kitchen-zero-split-settlement: zero settlement records no payment amount'); - const zeroPaymentDetails = db.prepare('SELECT payment_details FROM bills WHERE id = ?').get(checkB.id) as any; - assertEqual(JSON.parse(zeroPaymentDetails.payment_details || '[]').length, 0, 'kitchen-zero-split-settlement: no cash tender is recorded'); + assertEqual( + servedZeroBill.status, + 200, + 'kitchen-zero-split-settlement: served zero-balance check settles without a PIN', + ); + assertEqual( + Number(billRow(db, checkB.id).paid_amount), + 0, + 'kitchen-zero-split-settlement: zero settlement records no payment amount', + ); + const zeroPaymentDetails = db + .prepare('SELECT payment_details FROM bills WHERE id = ?') + .get(checkB.id) as any; + assertEqual( + JSON.parse(zeroPaymentDetails.payment_details || '[]').length, + 0, + 'kitchen-zero-split-settlement: no cash tender is recorded', + ); setSetting(db, 'require_open_shift', 'false'); setSetting(db, 'require_kitchen_delivered_before_settlement', 'false'); } // ── refunded sibling does not block completion ── - console.log('\n─── refunded-sibling-completion: refunded sibling does not block order completion ───'); + console.log( + '\n─── refunded-sibling-completion: refunded sibling does not block order completion ───', + ); { const create = await createOrder({ type: 'dine_in', @@ -329,33 +545,72 @@ async function main(): Promise { approver_id: approver, }); assertEqual(refA.status, 201, 'refunded-sibling-completion: refunded A'); - assertEqual(billRow(db, billA.id).payment_status, 'refunded', 'refunded-sibling-completion: A is refunded'); + assertEqual( + billRow(db, billA.id).payment_status, + 'refunded', + 'refunded-sibling-completion: A is refunded', + ); const payB = await pay(billB.id, { method: 'cash', amount: null }); assertEqual(payB.status, 200, 'refunded-sibling-completion: paid B'); const ordFinal = orderRow(db, orderId); - assertEqual(ordFinal.status, 'completed', 'refunded-sibling-completion: order completed after B paid + A refunded'); - assert(tableRow(db, 'table-zb')?.status === 'available', 'refunded-sibling-completion: table freed'); + assertEqual( + ordFinal.status, + 'completed', + 'refunded-sibling-completion: order completed after B paid + A refunded', + ); + assert( + tableRow(db, 'table-zb')?.status === 'available', + 'refunded-sibling-completion: table freed', + ); } // ── full discount with no prior payment settles ── - console.log('\n─── full-discount-no-payment: settle after 100% discount with paid_amount=0 ───'); + console.log( + '\n─── full-discount-no-payment: settle after 100% discount with paid_amount=0 ───', + ); { - const create = await createOrder({ type: 'takeaway', items: [{ product_id: 'prod-1000', quantity: 1 }] }); + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); assertEqual(create.status, 201, 'full-discount-no-payment: order created'); const orderId = create.data.order.id; const gen = await generateBill(orderId); assertEqual(gen.status, 201, 'full-discount-no-payment: bill generated'); const billId = gen.data.bill.id; - const disc = await applyBillDiscount(billId, { type: 'amount', value: 1000, reason: 'zb full-discount-no-payment' }); + const disc = await applyBillDiscount(billId, { + type: 'amount', + value: 1000, + reason: 'zb full-discount-no-payment', + }); assertEqual(disc.status, 200, 'full-discount-no-payment: 100% discount applied'); const afterDisc = billRow(db, billId); - assertEqual(Number(afterDisc.balance), 0, 'full-discount-no-payment: balance is 0 after discount'); - assertEqual(Number(afterDisc.paid_amount), 0, 'full-discount-no-payment: paid_amount stays 0'); + assertEqual( + Number(afterDisc.balance), + 0, + 'full-discount-no-payment: balance is 0 after discount', + ); + assertEqual( + Number(afterDisc.paid_amount), + 0, + 'full-discount-no-payment: paid_amount stays 0', + ); const settle = await pay(billId, { method: 'cash', amount: null }); - assert(settle.status >= 200 && settle.status < 300, `full-discount-no-payment: settle not rejected (got ${settle.status} ${JSON.stringify(settle.data)})`); - assertEqual(billRow(db, billId).payment_status, 'paid', 'full-discount-no-payment: bill is paid after settle'); - assertEqual(orderRow(db, orderId).status, 'completed', 'full-discount-no-payment: order completed after settle'); + assert( + settle.status >= 200 && settle.status < 300, + `full-discount-no-payment: settle not rejected (got ${settle.status} ${JSON.stringify(settle.data)})`, + ); + assertEqual( + billRow(db, billId).payment_status, + 'paid', + 'full-discount-no-payment: bill is paid after settle', + ); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'full-discount-no-payment: order completed after settle', + ); } // ── cancel is not blocked by a zero-closed sibling ── @@ -388,18 +643,32 @@ async function main(): Promise { }); assertEqual(split.status, 201, 'cancel-after-zero-close: split created'); const checkB = split.data.bills[1]; - const cancelB = await cancelItem(orderId, itemB.id, { reason: 'zb cancel-after-zero-close B' }); + const cancelB = await cancelItem(orderId, itemB.id, { + reason: 'zb cancel-after-zero-close B', + }); assertEqual(cancelB.status, 200, 'cancel-after-zero-close: B cancelled'); - assertEqual(billRow(db, checkB.id).payment_status, 'paid', 'cancel-after-zero-close: B zero-closed as paid'); - const cancelA = await cancelItem(orderId, itemA.id, { reason: 'zb cancel-after-zero-close A' }); - assertEqual(cancelA.status, 200, 'cancel-after-zero-close: A still cancellable despite zero-closed sibling'); + assertEqual( + billRow(db, checkB.id).payment_status, + 'paid', + 'cancel-after-zero-close: B zero-closed as paid', + ); + const cancelA = await cancelItem(orderId, itemA.id, { + reason: 'zb cancel-after-zero-close A', + }); + assertEqual( + cancelA.status, + 200, + 'cancel-after-zero-close: A still cancellable despite zero-closed sibling', + ); if (tableRow(db, 'table-zb')?.status !== 'available') { db.prepare(`UPDATE tables SET status = 'available' WHERE id = ?`).run('table-zb'); } } // ── partially refunded sibling does not block completion ── - console.log('\n─── partially-refunded-sibling: partially refunded sibling does not block completion ───'); + console.log( + '\n─── partially-refunded-sibling: partially refunded sibling does not block completion ───', + ); { const create = await createOrder({ type: 'dine_in', @@ -436,12 +705,23 @@ async function main(): Promise { approver_id: approver, }); assertEqual(refA.status, 201, 'partially-refunded-sibling: partial refund of A'); - assertEqual(billRow(db, billA.id).payment_status, 'partially_refunded', 'partially-refunded-sibling: A is partially_refunded'); + assertEqual( + billRow(db, billA.id).payment_status, + 'partially_refunded', + 'partially-refunded-sibling: A is partially_refunded', + ); const payB = await pay(billB.id, { method: 'cash', amount: null }); assertEqual(payB.status, 200, 'partially-refunded-sibling: paid B'); - assertEqual(orderRow(db, orderId).status, 'completed', 'partially-refunded-sibling: order completed after B paid + A partially refunded'); - assert(tableRow(db, 'table-zb')?.status === 'available', 'partially-refunded-sibling: table freed'); + assertEqual( + orderRow(db, orderId).status, + 'completed', + 'partially-refunded-sibling: order completed after B paid + A partially refunded', + ); + assert( + tableRow(db, 'table-zb')?.status === 'available', + 'partially-refunded-sibling: table freed', + ); } // ── restore re-syncs when all split bills were zero-closed ── @@ -461,7 +741,10 @@ async function main(): Promise { const itemA = items.find((i: any) => i.product_id === 'prod-1000'); const itemB = items.find((i: any) => i.product_id === 'prod-400'); - const disc = await itemDiscount(orderId, itemB.id, { discount_type: 'amount', discount_value: 400 }); + const disc = await itemDiscount(orderId, itemB.id, { + discount_type: 'amount', + discount_value: 400, + }); assertEqual(disc.status, 200, 'restore-after-zero-close: item B fully discounted'); const gen = await generateBill(orderId); @@ -480,11 +763,25 @@ async function main(): Promise { const checkA = split.data.bills[0]; const checkB = split.data.bills[1]; - const cancelA = await cancelItem(orderId, itemA.id, { reason: 'zb restore-after-zero-close A' }); + const cancelA = await cancelItem(orderId, itemA.id, { + reason: 'zb restore-after-zero-close A', + }); assertEqual(cancelA.status, 200, 'restore-after-zero-close: A cancelled'); - assertEqual(billRow(db, checkA.id).payment_status, 'paid', 'restore-after-zero-close: check A zero-closed as paid'); - assertEqual(billRow(db, checkB.id).payment_status, 'paid', 'restore-after-zero-close: check B zero-closed as paid'); - assertEqual(orderRow(db, orderId).status, 'pending', 'restore-after-zero-close: order still open (B active)'); + assertEqual( + billRow(db, checkA.id).payment_status, + 'paid', + 'restore-after-zero-close: check A zero-closed as paid', + ); + assertEqual( + billRow(db, checkB.id).payment_status, + 'paid', + 'restore-after-zero-close: check B zero-closed as paid', + ); + assertEqual( + orderRow(db, orderId).status, + 'pending', + 'restore-after-zero-close: order still open (B active)', + ); const restore = await api(baseUrl, `/api/orders/${orderId}/items/${itemA.id}/restore`, { method: 'PATCH', @@ -493,17 +790,29 @@ async function main(): Promise { }); assertEqual(restore.status, 200, 'restore-after-zero-close: A restored'); const aAfter = billRow(db, checkA.id); - assertEqual(aAfter.payment_status, 'unpaid', 'restore-after-zero-close: check A flipped back to unpaid after restore'); - assert(Number(aAfter.total) > 0, `restore-after-zero-close: check A total re-synced (got ${aAfter.total})`); + assertEqual( + aAfter.payment_status, + 'unpaid', + 'restore-after-zero-close: check A flipped back to unpaid after restore', + ); + assert( + Number(aAfter.total) > 0, + `restore-after-zero-close: check A total re-synced (got ${aAfter.total})`, + ); if (tableRow(db, 'table-zb')?.status !== 'available') { db.prepare(`UPDATE tables SET status = 'available' WHERE id = ?`).run('table-zb'); } } // ── settle attempt on a refunded bill is rejected ── - console.log('\n─── refunded-settle-rejected: settle attempt on refunded bill stays refunded ───'); + console.log( + '\n─── refunded-settle-rejected: settle attempt on refunded bill stays refunded ───', + ); { - const create = await createOrder({ type: 'takeaway', items: [{ product_id: 'prod-1000', quantity: 1 }] }); + const create = await createOrder({ + type: 'takeaway', + items: [{ product_id: 'prod-1000', quantity: 1 }], + }); assertEqual(create.status, 201, 'refunded-settle-rejected: order created'); const gen = await generateBill(create.data.order.id); assertEqual(gen.status, 201, 'refunded-settle-rejected: bill generated'); @@ -520,21 +829,38 @@ async function main(): Promise { approver_id: approver, }); assertEqual(ref.status, 201, 'refunded-settle-rejected: bill refunded'); - assertEqual(billRow(db, billId).payment_status, 'refunded', 'refunded-settle-rejected: status is refunded'); + assertEqual( + billRow(db, billId).payment_status, + 'refunded', + 'refunded-settle-rejected: status is refunded', + ); const settle = await pay(billId, { method: 'cash', amount: null }); - assertEqual(settle.status, 409, `refunded-settle-rejected: settle rejected (got ${settle.status})`); - assertEqual(billRow(db, billId).payment_status, 'refunded', 'refunded-settle-rejected: status still refunded after settle attempt'); + assertEqual( + settle.status, + 409, + `refunded-settle-rejected: settle rejected (got ${settle.status})`, + ); + assertEqual( + billRow(db, billId).payment_status, + 'refunded', + 'refunded-settle-rejected: status still refunded after settle attempt', + ); } // ── restore blocked when bill has a positive partial payment ── - console.log('\n─── restore-partial-payment: restore rejected when bill has a positive partial payment ───'); + console.log( + '\n─── restore-partial-payment: restore rejected when bill has a positive partial payment ───', + ); { // Two items: cancel the cheaper one so the remaining bill is 1000, then partially pay. - const create = await createOrder({ type: 'takeaway', items: [ - { product_id: 'prod-1000', quantity: 1 }, - { product_id: 'prod-400', quantity: 1 }, - ]}); + const create = await createOrder({ + type: 'takeaway', + items: [ + { product_id: 'prod-1000', quantity: 1 }, + { product_id: 'prod-400', quantity: 1 }, + ], + }); assertEqual(create.status, 201, 'restore-partial-payment: order created'); const orderId = create.data.order.id; const gen = await generateBill(orderId); @@ -546,14 +872,28 @@ async function main(): Promise { const items = orderData.data?.order?.items ?? []; const item400 = items.find((i: any) => i.product_id === 'prod-400'); const item1000 = items.find((i: any) => i.product_id === 'prod-1000'); - const cancelR = await cancelItem(orderId, item400.id, { reason: 'zb restore-partial-payment cancel 400' }); + const cancelR = await cancelItem(orderId, item400.id, { + reason: 'zb restore-partial-payment cancel 400', + }); assertEqual(cancelR.status, 200, 'restore-partial-payment: prod-400 item cancelled'); // Partial payment on the remaining 1000-total bill const partialPay = await pay(billId, { method: 'cash', amount: 300 }); - assertEqual(partialPay.status, 200, 'restore-partial-payment: partial payment of 300 accepted'); - assertEqual(billRow(db, billId).payment_status, 'partial', 'restore-partial-payment: bill status is partial after partial payment'); - assertEqual(Number(billRow(db, billId).paid_amount) > 0, true, 'restore-partial-payment: paid_amount is positive'); + assertEqual( + partialPay.status, + 200, + 'restore-partial-payment: partial payment of 300 accepted', + ); + assertEqual( + billRow(db, billId).payment_status, + 'partial', + 'restore-partial-payment: bill status is partial after partial payment', + ); + assertEqual( + Number(billRow(db, billId).paid_amount) > 0, + true, + 'restore-partial-payment: paid_amount is positive', + ); // Restore the cancelled item — must be rejected because the bill has a positive partial payment const restore = await api(baseUrl, `/api/orders/${orderId}/items/${item400.id}/restore`, { @@ -561,8 +901,16 @@ async function main(): Promise { body: { reason: 'zb restore-partial-payment' }, headers: A, }); - assertEqual(restore.status, 400, 'restore-partial-payment: restore rejected when partial payment recorded'); - assertEqual(billRow(db, billId).payment_status, 'partial', 'restore-partial-payment: bill stays partial after rejected restore'); + assertEqual( + restore.status, + 400, + 'restore-partial-payment: restore rejected when partial payment recorded', + ); + assertEqual( + billRow(db, billId).payment_status, + 'partial', + 'restore-partial-payment: bill stays partial after rejected restore', + ); } } finally { server.close();