From 72286c1955984bf9f31e049b09efb48842520a3c Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 20:39:40 -0400 Subject: [PATCH 1/5] fix(orders): guard financial status changes Fixes #1056 Fixes #1057 --- docs/architecture/order-lifecycle.md | 11 +- docs/reference/api.md | 26 +- docs/reference/roles-and-permissions.md | 14 +- frontend/e2e/ops-admin/order-history.spec.ts | 40 ++- .../e2e/ops-admin/orders-lifecycle.spec.ts | 60 +++++ .../e2e/table-reservation-customer.spec.ts | 2 +- frontend/src/app/(dashboard)/orders/page.tsx | 42 ++- frontend/src/components/orders/OrderCard.tsx | 18 +- .../components/orders/OrderDetailPanel.tsx | 18 +- main/routes/orders.ts | 242 ++++++++++++++++-- tests/addon-inventory-lifecycle.test.ts | 15 ++ tests/cancel-override.test.ts | 95 ++++++- ...ssue-252-order-lifecycle-inventory.test.ts | 4 +- tests/orders-authz.test.ts | 171 ++++++++++++- tests/recipe-order-lifecycle.test.ts | 26 ++ 15 files changed, 724 insertions(+), 60 deletions(-) diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index 5ff819aec..f6a458e12 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -166,7 +166,16 @@ enabled, excluding the share funded by wallet points. ## Order status `PATCH /api/orders/:id/status` advances the order through preparation, service, and terminal -statuses. Its allowed transitions, no-op rule, and cancellation approval requirements are in the +statuses inside a single transaction. Ordinary `completed` transitions require settled payable bills +covering the order total (an unbilled positive-total order is not considered paid) and, when +`require_kitchen_delivered_before_settlement` applies, no kitchen items remaining in `pending`, +`preparing`, or `ready`. Unsettled completion or undelivered-kitchen completion requires an explicit +active owner or manager `override_pin` (being logged in as owner or manager does not bypass the PIN) +and leaves bill balances and payment records untouched. Cancelling an order requires an explicit +active owner or manager `override_pin` when the order or any item has progressed past `pending` or +when any bill holds collected funds; cancellation preserves payment and refund records and restores +product, variant, add-on, and recipe stock only for items still in `pending` status. Allowed +transitions, audit fields, and approval rules are in the [API reference](../reference/api.md#orders). Kitchen item status is a separate surface (`/api/order-items`); see [order-item status](../reference/api.md#order-item-status). diff --git a/docs/reference/api.md b/docs/reference/api.md index 984735b47..3c858ff7f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -276,7 +276,7 @@ and settlement - is in [Order lifecycle](../architecture/order-lifecycle.md). | `GET` | `/:id` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | path: `id` | `{ ...order, items, table }`. | | `POST` | `/` | `ROLE_ACCESS.sales` + order write limiter | body: `items`, `table_id`, `customer_id`, `type`, `guest_count`, `special_instructions`, `packaging_charge`, `delivery_charge`, `service_charge`, `online_platform`, `external_order_id`, `delivery_address`, `expected_payment_method`, `expected_payment_method_id`, `delivery_note`; header: `Idempotency-Key` | `201` with the created order. Repeating the same request body with an `Idempotency-Key` returns its stored response; reusing the key with a different body returns `409`. `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: without an id, the method keeps its legacy string/sentinel resolution (`unknown` by default, `pending`, `cash`, `card`, or an active custom method name), and is never recorded as a payment; `400` for any other method or a note over 200 characters. A custom method whose name matches a built-in or sentinel needs its id to be identified as custom. An optional `expected_payment_method_id` must be a positive safe integer naming an **active** configured method; the server stores its canonical name and requires any supplied non-empty name to match case-insensitively. Invalid, unknown, or inactive ids return `400`. The id is a historical marker, not a foreign key, so renaming or deleting the method later never rewrites an order; only `orders.expected_payment_method_id` decides that the stored name prints literally on a delivery slip. Non-delivery orders persist both expected-method fields as `null`. An item's `variant_id` is required when the product has active variants, and `400` when it names an unknown, foreign, or inactive variant; `unit_price` is never taken from the client. | | `POST` | `/:id/items` | `ROLE_ACCESS.sales` + order write limiter | path: `id`; body: `items`, `special_instructions`; header: `Idempotency-Key` | Appends items and returns the recomputed order. Honours `Idempotency-Key`. Items resolve a `variant_id` under the same rules as order creation, priced with `variant.online_price` when the order carries an `online_platform`. | -| `PATCH` | `/:id/status` | `ROLE_ACCESS.orderStatus` + order write limiter | path: `id`; body: `status`, `reason`, `override_pin`, `free_table` | 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. | +| `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` | 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` | - | @@ -290,6 +290,30 @@ header. A validation rejection with a registered failure point includes `reason_ present. A valid supplied UUID is returned as the correlation id, otherwise the server generates one. Failures without a registered tuple may retain the `{ error }` response. +`PATCH /:id/status` evaluates order, bill/payment, and kitchen state inside the transaction: + +- Ordinary `completed` transitions require every payable bill for the order to be settled (`paid` with + zero unpaid balance) and billed totals (accounting for `round_off`) to cover `order.total`. An + unbilled positive-total order is not considered paid. When + `require_kitchen_delivered_before_settlement` is enabled (`kds_enabled` not `false` and + `billing_type` not `prepaid`), any item still in `pending`, `preparing`, or `ready` also blocks + ordinary completion. +- When unsettled payment or the applicable kitchen-delivery requirement blocks `completed`, an + explicit `override_pin` from an active `owner` or `manager` holding `orders.status.update` (plus + `payments.take` when overriding unsettled payment) is required (`400` when missing, `403` when + invalid or unauthorized, `429` after 5 failed attempts). Being logged in as an owner or manager + does not bypass `override_pin`. Overriding completion never marks bills paid, erases balances, or + creates payment records. +- `cancelled` transitions require an explicit `override_pin` from an active `owner` or `manager` + holding `orders.status.update` and `orders.item.cancel` whenever the order has advanced past + `pending`, any item is in `preparing`, `ready`, `served`, or `completed`, or any bill holds + recorded collected funds. Cancellation preserves bill payment and refund records (refunds remain a + separate operation via `/api/refunds`) and restores product, variant, add-on, and recipe stock + only for items still in `pending` status. +- Every overridden `status_changed` audit log entry records `actor_user_id`, `approved_by`, + `overridden_safeguards`, `bill_ids`, `outstanding_amount`, `outstanding_amounts`, and + `collected_amount`, and never records the PIN. + Both item endpoints resolve the caller's effective permissions from the database inside a `withTxn` (deferred, not immediate) transaction rather than from the JWT claim, and re-run every policy check there. Each opens the single transaction scope it runs in; the route does not wrap the handler diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index 57dd987af..7dfd4e390 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -153,10 +153,16 @@ session, and the closing actor is recorded on the closure row. The route is where the stored session and the actor are read together. This is a context policy, not a permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md). -**Order cancellation.** Cashiers can cancel a whole order while it is pending. If the order or any -item has advanced to `preparing` or later, an owner or manager approval PIN is required. The rule -turns on stored order and item status, not on whether a KDS screen is open; printing a kitchen -ticket does not advance the status. +**Order cancellation and completion overrides.** Staff holding `orders.status.update` can cancel a +whole order without a PIN only while the order and all of its items are `pending` and no bill holds +collected funds. If the order or any item has advanced to `preparing` or later, or if any bill holds +recorded collected payment, an active owner or manager approval PIN (`override_pin`, with +`orders.status.update` and `orders.item.cancel`) is required; cancellation preserves payment and +refund records and restores inventory only for `pending` items. Similarly, completing an order with +unsettled payable bills, an unbilled positive total, or undelivered kitchen items (when +`require_kitchen_delivered_before_settlement` applies) requires an explicit active owner or manager +PIN (with `payments.take` when overriding unsettled payment), even when the logged-in user is +already an owner or manager. **Owner visibility.** The permission editor itself is rendered only for an authenticated user holding `authorization.manage` — an active owner, always. The API enforces this independently; diff --git a/frontend/e2e/ops-admin/order-history.spec.ts b/frontend/e2e/ops-admin/order-history.spec.ts index 0bc3a7de4..eff0e1438 100644 --- a/frontend/e2e/ops-admin/order-history.spec.ts +++ b/frontend/e2e/ops-admin/order-history.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type APIRequestContext } from '@playwright/test'; import { E2E_BASE_URL } from '../helpers/urls'; import { login, placeOrderAndPayCash, addToCart, ownerAuth, money, fmt } from './helpers'; @@ -18,6 +18,20 @@ let settled: Array<{ order_number: string; total: number; subtotal: number; cust let open: { order_number: string }; let customerName: string; +async function settleOrderViaBill(request: APIRequestContext, headers: Record, orderId: number) { + const billRes = await request.post(`${E2E_BASE_URL}/api/bills/generate`, { + headers, + data: { order_id: orderId }, + }); + expect(billRes.ok()).toBeTruthy(); + const { bill } = (await billRes.json()) as { bill: { id: number; total: number } }; + const payRes = await request.post(`${E2E_BASE_URL}/api/bills/${bill.id}/payment`, { + headers, + data: { method: 'cash', amount: bill.total }, + }); + expect(payRes.ok()).toBeTruthy(); +} + test.describe('@ci-tier2 operations admin - order history', () => { test.beforeAll(async ({ request }) => { // A customer to attach one sale to, so search-by-name and search-by-phone @@ -42,11 +56,7 @@ test.describe('@ci-tier2 operations admin - order history', () => { expect(res.status()).toBe(201); const { order } = (await res.json()) as { order: { id: number; order_number: string; total: number; subtotal: number } }; // Settle it the same way the POS does, so it lands in history as completed. - const completed = await request.patch(`${E2E_BASE_URL}/api/orders/${order.id}/status`, { - headers: ownerAuth(), - data: { status: 'completed' }, - }); - expect(completed.ok()).toBeTruthy(); + await settleOrderViaBill(request, ownerAuth(), order.id); settled.push(order); } @@ -65,11 +75,7 @@ test.describe('@ci-tier2 operations admin - order history', () => { // Settle it too, so every entry in `settled` really is a settled sale and // the Completed filter is being asked a fair question. - const completedDelivery = await request.patch(`${E2E_BASE_URL}/api/orders/${delivery.id}/status`, { - headers: ownerAuth(), - data: { status: 'completed' }, - }); - expect(completedDelivery.ok()).toBeTruthy(); + await settleOrderViaBill(request, ownerAuth(), delivery.id); settled.push({ ...delivery, customer: customerName }); const openRes = await request.post(`${E2E_BASE_URL}/api/orders`, { @@ -155,11 +161,7 @@ test.describe('@ci-tier2 operations admin - order history', () => { data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 1 }] }, }); const { order } = (await created.json()) as { order: { id: number; order_number: string } }; - const completed = await request.patch(`${E2E_BASE_URL}/api/orders/${order.id}/status`, { - headers, - data: { status: 'completed' }, - }); - expect(completed.ok()).toBeTruthy(); + await settleOrderViaBill(request, headers, order.id); await page.goto(`${E2E_BASE_URL}/orders`); @@ -274,11 +276,7 @@ test.describe('@ci-tier2 operations admin - order history', () => { }); expect(created.status()).toBe(201); const sale = ((await created.json()) as { order: { id: number; order_number: string; subtotal: number; total: number } }).order; - const completed = await request.patch(`${E2E_BASE_URL}/api/orders/${sale.id}/status`, { - headers, - data: { status: 'completed' }, - }); - expect(completed.ok()).toBeTruthy(); + await settleOrderViaBill(request, headers, sale.id); await page.goto(`${E2E_BASE_URL}/orders`); await page.getByRole('button', { name: 'All', exact: true }).first().click(); diff --git a/frontend/e2e/ops-admin/orders-lifecycle.spec.ts b/frontend/e2e/ops-admin/orders-lifecycle.spec.ts index dd807db22..fad827268 100644 --- a/frontend/e2e/ops-admin/orders-lifecycle.spec.ts +++ b/frontend/e2e/ops-admin/orders-lifecycle.spec.ts @@ -181,6 +181,66 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { expect(await fetchProductStock(page, cancelProbe.id)).toBe(before); }); + test('cancelling a pending order with collected payment requires a manager PIN and keeps the dialog open on error', async ({ page }) => { + await login(page, 'owner'); + const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + + const created = await page.request.post(`${E2E_BASE_URL}/api/orders`, { + headers, + data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 2 }] }, + }); + expect(created.status()).toBe(201); + const { order } = (await created.json()) as { order: { id: number; order_number: string; total: number } }; + + const billGen = await page.request.post(`${E2E_BASE_URL}/api/bills/generate`, { + headers, + data: { order_id: order.id }, + }); + expect(billGen.status()).toBe(201); + const { bill } = (await billGen.json()) as { bill: { id: number; total: number } }; + + // Record a partial payment so the order remains active and pending while holding collected funds. + const partialPay = await page.request.post(`${E2E_BASE_URL}/api/bills/${bill.id}/payment`, { + headers, + data: { method: 'cash', amount: 20 }, + }); + expect(partialPay.ok()).toBeTruthy(); + + await page.goto(`${E2E_BASE_URL}/orders`); + const row = page.getByRole('button').filter({ hasText: `#${order.order_number}` }).first(); + await expect(row).toBeVisible(); + await row.click(); + + await openOrderOverflowMenu(page); + await page.getByRole('menuitem', { name: /^Cancel$/ }).click(); + + const sheet = overlay(page); + await expect(sheet).toBeVisible(); + await expect(sheet).toContainText(`Cancel Order #${order.order_number}`); + const pinInput = sheet.locator('#overridePin'); + await expect(pinInput).toBeVisible(); + + // Recoverable rejection (wrong PIN) keeps the dialog open so staff can correct the PIN. + await pinInput.fill('9999'); + await sheet.getByRole('button', { name: /Confirm Cancel/i }).click(); + await expect(sheet).toBeVisible(); + await expect(pinInput).toBeVisible(); + expect((await fetchOrder(page, order.order_number)).status).toBe('pending'); + + // Supplying the valid manager/owner PIN completes the cancellation without erasing collected payment. + await pinInput.fill(OWNER_APPROVAL_PIN); + await sheet.getByRole('button', { name: /Confirm Cancel/i }).click(); + await expect(page.getByText(/Order cancelled successfully/i).first()).toBeVisible({ timeout: 30_000 }); + + const cancelled = await fetchOrder(page, order.order_number); + expect(cancelled.status).toBe('cancelled'); + const billRes = await page.request.get(`${E2E_BASE_URL}/api/bills/${bill.id}`, { headers }); + expect(billRes.ok()).toBeTruthy(); + const { bill: storedBill } = (await billRes.json()) as { bill: { paid_amount: number; payment_status: string } }; + expect(Number(storedBill.paid_amount)).toBe(20); + expect(storedBill.payment_status).toBe('partial'); + }); + test('the Orders screen shows this order\'s own totals, and each line its own gross', async ({ page }) => { await login(page, 'owner'); const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); diff --git a/frontend/e2e/table-reservation-customer.spec.ts b/frontend/e2e/table-reservation-customer.spec.ts index fafdd0db9..3d2b4eaa8 100644 --- a/frontend/e2e/table-reservation-customer.spec.ts +++ b/frontend/e2e/table-reservation-customer.spec.ts @@ -153,7 +153,7 @@ test('reserved customer is searchable, shown after reload, and linked to the din if (orderId) { await page.request.patch(`${BASE}/api/orders/${orderId}/status`, { headers: authHeaders, - data: { status: 'completed' }, + data: { status: 'cancelled' }, }); } if (tableId) { diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index e16f09e5d..b2f6e3599 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -85,6 +85,35 @@ interface CancelModal { reason: string; freeTable: boolean; overridePin: string; + requiresPin?: boolean; +} + +function orderHasCollectedPayment(order: Order): boolean { + const bills = order.bills && order.bills.length > 0 ? order.bills : order.bill ? [order.bill] : []; + return bills.some((candidate) => { + const status = String(candidate.payment_status || ''); + const paidAmount = Number(candidate.paid_amount || 0); + const total = Number(candidate.total || 0); + const detailsCount = Array.isArray(candidate.payment_details) + ? candidate.payment_details.length + : 0; + return ( + (paidAmount > 0 && status !== 'refunded') || + status === 'partial' || + status === 'partially_refunded' || + (status === 'paid' && (total > 0 || detailsCount > 0)) + ); + }); +} + +function orderRequiresCancelPin(order: Order): boolean { + return ( + order.status !== 'pending' || + Boolean( + order.items?.some((i) => ['preparing', 'ready', 'served', 'completed'].includes(i.status)), + ) || + orderHasCollectedPayment(order) + ); } interface VoidItemModal { @@ -1346,12 +1375,16 @@ export default function OrdersPage() { override_pin: cancelModal.overridePin || undefined, }); toast.success(tOrders('orderCancelled')); + setCancelModal(null); fetchOrders(undefined, { refreshLoadedPages: true }); - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (/PIN/i.test(message)) { + setCancelModal((prev) => (prev ? { ...prev, requiresPin: true } : prev)); + } toast.error(tOrders('cancelOrderFailed')); } finally { setCancellingOrderId(null); - setCancelModal(null); } }; @@ -1854,10 +1887,7 @@ export default function OrdersPage() { )} - {(cancelModal.order.status !== 'pending' || - cancelModal.order.items?.some((i) => - ['preparing', 'ready', 'served', 'completed'].includes(i.status), - )) && ( + {(cancelModal.requiresPin || orderRequiresCancelPin(cancelModal.order)) && (
{/* Add-ons subline */} {item.addons && item.addons.length > 0 && (
- {item.addons.map((a) => `+ ${a.name}${(a.quantity || 1) > 1 ? ` ×${a.quantity}` : ''}`).join(' · ')} + {item.addons + .map((a) => `+ ${a.name}${(a.quantity || 1) > 1 ? ` ×${a.quantity}` : ''}`) + .join(' · ')}
)} @@ -655,8 +746,15 @@ export function OrderCard({ aria-controls={`order-items-${order.id}`} className="inline-flex items-center gap-1.5 px-3 py-1.5 min-h-[32px] rounded-full text-xs font-medium bg-muted hover:bg-muted/80 text-foreground transition-colors touch-manipulation active:scale-95" > - - {showAllItems ? tCommon('back') : tOrders('moreItems', { count: hiddenItemCount })} + + + {showAllItems + ? tCommon('back') + : tOrders('moreItems', { count: hiddenItemCount })} + )} @@ -670,29 +768,42 @@ export function OrderCard({ > {tOrders('voidedItemsCount', { count: inactiveItems.length })} - + )} {/* Expanded voided items list */} {showVoidedItems && inactiveItems.length > 0 && canRestoreItems && ( -
+
{inactiveItems.map((cItem: OrderItem) => ( -
+
- {cItem.quantity}x {formatItemHeading(cItem.product_name, cItem.variant_selection)} + {cItem.quantity}x{' '} + {formatItemHeading(cItem.product_name, cItem.variant_selection)} - {cItem.status === 'cancelled' && !isPaid && !['completed', 'cancelled'].includes(order.status) && onRestoreItem && ( - - )} + {cItem.status === 'cancelled' && + !isPaid && + !['completed', 'cancelled'].includes(order.status) && + onRestoreItem && ( + + )}
))}
@@ -790,10 +901,14 @@ export function OrderCard({ {/* Expanded Print Activity Log */} {showPrintActivity && orderPrints.length > 0 && ( -
+
{orderPrints.map((p, idx) => (
- {idx + 1}. {tOrders('printHistoryEntry', { + {idx + 1}.{' '} + {tOrders('printHistoryEntry', { printedType: p.print_type === 'reprint' ? tOrders('reprint') : tOrders('printed'), user: p.user_name, time: formatDateTime(p.printed_at), diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index 773ae528e..f30fe6c75 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -10,9 +10,33 @@ import { } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { - Utensils, ShoppingBag, Truck, Globe, Clock, Printer, MoreHorizontal, User, Plus, - ChevronDown, ChevronRight, ChevronLeft, CreditCard, RotateCcw, Send, AlertCircle, - MapPin, Info, FileText, Ban, Trash2, Lock, XCircle, Download, Loader2, ClipboardList, Bike, + Utensils, + ShoppingBag, + Truck, + Globe, + Clock, + Printer, + MoreHorizontal, + User, + Plus, + ChevronDown, + ChevronRight, + ChevronLeft, + CreditCard, + RotateCcw, + Send, + AlertCircle, + MapPin, + Info, + FileText, + Ban, + Trash2, + Lock, + XCircle, + Download, + Loader2, + ClipboardList, + Bike, } from 'lucide-react'; import { useTranslations } from 'use-intl'; import type { Order, OrderItem, Bill, Customer } from '@/lib/types'; @@ -39,7 +63,10 @@ export interface OrderDetailPanelProps { canRestoreItems: boolean; canRefund: boolean; isWhatsAppReady: boolean; - printHistory: Record; + printHistory: Record< + number, + { id: number; print_type: string; user_name: string; printed_at: string }[] + >; generatingBillId: number | null; printingBillId: number | null; printingSlipOrderId?: number | null; @@ -127,7 +154,9 @@ function OrderDetailContent({ const activeItems = (order.items || []).filter( (i) => !['cancelled', 'voided', 'void_adjustment'].includes(i.status), ); - const inactiveItems = (order.items || []).filter((i) => ['cancelled', 'voided'].includes(i.status)); + const inactiveItems = (order.items || []).filter((i) => + ['cancelled', 'voided'].includes(i.status), + ); const bill = order.bill; const isPaid = bill?.payment_status === 'paid'; @@ -141,15 +170,20 @@ function OrderDetailContent({ const orderBills = order.bills && order.bills.length > 0 ? order.bills : bill ? [bill] : []; const splitBills = orderBills.filter((candidate) => Boolean(candidate.split_group_id)); - const canPaySplitBill = (candidate: Bill) => candidate.payment_status === 'unpaid' || candidate.payment_status === 'partial'; + const canPaySplitBill = (candidate: Bill) => + candidate.payment_status === 'unpaid' || candidate.payment_status === 'partial'; const hasUnpaidSplitBills = splitBills.some(canPaySplitBill); - const paidBills = orderBills.filter((b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded'); + const paidBills = orderBills.filter( + (b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded', + ); const hasEligibleRefund = paidBills.length > 0; const hasCollectedFunds = orderBills.some((candidate) => { const status = String(candidate.payment_status || ''); const paidAmount = Number(candidate.paid_amount || 0); const total = Number(candidate.total || 0); - const detailsCount = Array.isArray(candidate.payment_details) ? candidate.payment_details.length : 0; + const detailsCount = Array.isArray(candidate.payment_details) + ? candidate.payment_details.length + : 0; return ( (paidAmount > 0 && status !== 'refunded') || status === 'partial' || @@ -159,28 +193,39 @@ function OrderDetailContent({ }); const cancelRequiresPin = order.status !== 'pending' || - Boolean(order.items?.some((i) => ['preparing', 'ready', 'served', 'completed'].includes(i.status))) || + Boolean( + order.items?.some((i) => ['preparing', 'ready', 'served', 'completed'].includes(i.status)), + ) || hasCollectedFunds; - const hasPaidBill = order.bills?.some((candidate) => candidate.payment_status === 'paid') ?? isPaid; - const receiptStatusBadge = isWhatsAppReady && hasPaidBill && order.whatsapp_receipt_status - ? whatsappReceiptStatusBadge[order.whatsapp_receipt_status] || null - : null; + const hasPaidBill = + order.bills?.some((candidate) => candidate.payment_status === 'paid') ?? isPaid; + const receiptStatusBadge = + isWhatsAppReady && hasPaidBill && order.whatsapp_receipt_status + ? whatsappReceiptStatusBadge[order.whatsapp_receipt_status] || null + : null; const receiptStatusLabel = receiptStatusBadge - ? receiptStatusBadge.labelKey === 'sent' ? tOrders('receiptSent') - : receiptStatusBadge.labelKey === 'failed' ? tOrders('sendFailed') - : tWhatsappStatus(receiptStatusBadge.labelKey) + ? receiptStatusBadge.labelKey === 'sent' + ? tOrders('receiptSent') + : receiptStatusBadge.labelKey === 'failed' + ? tOrders('sendFailed') + : tWhatsappStatus(receiptStatusBadge.labelKey) : ''; const subtotal = bill ? Number(bill.subtotal) : Number(order.subtotal); const discount = bill ? Number(bill.discount_amount) : Number(order.discount_amount); const tax = bill ? Number(bill.tax_amount) : Number(order.tax_amount); const total = bill ? Number(bill.total) : Number(order.total); - const deliveryCharge = bill ? Number(bill.delivery_charge || 0) : Number(order.delivery_charge || 0); + const deliveryCharge = bill + ? Number(bill.delivery_charge || 0) + : Number(order.delivery_charge || 0); const serviceCharge = bill ? Number(bill.service_charge || 0) : Number(order.service_charge || 0); - const packagingCharge = bill ? Number(bill.packaging_charge || 0) : Number(order.packaging_charge || 0); - const customCharges = receiptChargeLines(bill ? bill.charges_breakdown : order.charges_breakdown) - .filter((charge) => charge.id !== 'service_charge' && charge.id !== 'packaging_charge'); + const packagingCharge = bill + ? Number(bill.packaging_charge || 0) + : Number(order.packaging_charge || 0); + const customCharges = receiptChargeLines( + bill ? bill.charges_breakdown : order.charges_breakdown, + ).filter((charge) => charge.id !== 'service_charge' && charge.id !== 'packaging_charge'); const statusBadgeInfo = orderStatusBadge[order.status]; const orderPrints = bill?.id ? printHistory[bill.id] || [] : []; @@ -193,16 +238,23 @@ function OrderDetailContent({ return tCommon('timeHoursMinutesAgo', { h: Math.floor(minutes / 60), m: minutes % 60 }); }; - const checkoutButtonLabel = generatingBillId === order.id - ? tOrders('generating') - : payStatus === 'partial' ? tOrders('takePayment') : tOrders('checkout'); + const checkoutButtonLabel = + generatingBillId === order.id + ? tOrders('generating') + : payStatus === 'partial' + ? tOrders('takePayment') + : tOrders('checkout'); const orderTypeIcon = (() => { switch (order.type) { - case 'dine_in': return ; - case 'delivery': return ; - case 'online': return ; - default: return ; + case 'dine_in': + return ; + case 'delivery': + return ; + case 'online': + return ; + default: + return ; } })(); @@ -216,18 +268,23 @@ function OrderDetailContent({ #{order.order_number} {statusBadgeInfo && ( - + {tOrders(statusBadgeInfo.labelKey)} )} {payBadge && ( - + {tOrders(payBadge.labelKey)} )}
- {(bill || (onPrintOrder && order.type === 'dine_in' && order.status !== 'cancelled')) && ( + {(bill || + (onPrintOrder && order.type === 'dine_in' && order.status !== 'cancelled')) && ( )} @@ -256,9 +315,14 @@ function OrderDetailContent({ {order.type === 'dine_in' && !['completed', 'cancelled'].includes(order.status) && ( - onConvertToTakeaway(order)} disabled={convertingOrderId === order.id}> + onConvertToTakeaway(order)} + disabled={convertingOrderId === order.id} + > - {convertingOrderId === order.id ? tOrders('converting') : tOrders('convertToTakeaway')} + {convertingOrderId === order.id + ? tOrders('converting') + : tOrders('convertToTakeaway')} )} {!['completed', 'cancelled'].includes(order.status) && ( @@ -281,9 +345,11 @@ function OrderDetailContent({ disabled={cancellingOrderId === order.id} className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/40" > - {!cancelRequiresPin - ? - : } + {!cancelRequiresPin ? ( + + ) : ( + + )} {cancellingOrderId === order.id ? tOrders('cancelling') : tCommon('cancel')} @@ -309,7 +375,9 @@ function OrderDetailContent({
{receiptStatusBadge && ( - + {receiptStatusBadge.labelKey === 'sent' && } {receiptStatusBadge.labelKey === 'failed' && } {receiptStatusLabel} @@ -329,7 +397,9 @@ function OrderDetailContent({ time: formatTime(order.cancelled_at || order.created_at), reason: order.cancellation_reason, }) - : tOrders('orderCancelledBannerNoReason', { time: formatTime(order.cancelled_at || order.created_at) })} + : tOrders('orderCancelledBannerNoReason', { + time: formatTime(order.cancelled_at || order.created_at), + })}
)} @@ -346,7 +416,11 @@ function OrderDetailContent({ className="flex-1 px-3 py-1.5 text-xs sm:text-sm border border-border bg-card rounded-lg focus:ring-2 focus:ring-blue-500 outline-none" autoFocus /> -
@@ -362,9 +436,17 @@ function OrderDetailContent({ >
{customer.name} - {customer.phone && {customer.phone}} + {customer.phone && ( + + {customer.phone} + + )}
- {linkingCustomer && {tOrders('linking')}} + {linkingCustomer && ( + + {tOrders('linking')} + + )} ))}
@@ -376,7 +458,9 @@ function OrderDetailContent({ className="w-full flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-blue-600 dark:text-blue-400 bg-card hover:bg-blue-50 dark:hover:bg-blue-950/40 rounded-md border border-dashed border-blue-300 dark:border-blue-700 font-medium text-start disabled:opacity-50 transition-colors" > - {linkCustomerSearch?.trim() ? `${tPos('addCustomer')} "${linkCustomerSearch.trim()}"` : tPos('addCustomer')} + {linkCustomerSearch?.trim() + ? `${tPos('addCustomer')} "${linkCustomerSearch.trim()}"` + : tPos('addCustomer')}
) : ( @@ -389,7 +473,9 @@ function OrderDetailContent({ {order.customer.name} {order.customer.phone && ( - {order.customer.phone} + + {order.customer.phone} + )} {order.type === 'delivery' && order.customer.address && ( @@ -399,7 +485,9 @@ function OrderDetailContent({ )} ) : ( - {tOrders('walkInCustomer')} + + {tOrders('walkInCustomer')} + )} {order.customer && ( @@ -439,7 +527,10 @@ function OrderDetailContent({ {tOrders('orderNoteTap', { count: 1 })} - + {showOrderNotes && (
@@ -458,45 +549,59 @@ function OrderDetailContent({
- + {item.quantity}x - {heading} + + {heading} +
- {fmt(Number(item.total))} - {canCancelItems && !isPaid && !['completed', 'cancelled'].includes(order.status) && ( -
- {item.status === 'pending' && onDeleteItem && ( - - )} - {(item.status === 'preparing' || item.status === 'ready') && onVoidItem && ( - - )} -
- )} + + {fmt(Number(item.total))} + + {canCancelItems && + !isPaid && + !['completed', 'cancelled'].includes(order.status) && ( +
+ {item.status === 'pending' && onDeleteItem && ( + + )} + {(item.status === 'preparing' || item.status === 'ready') && + onVoidItem && ( + + )} +
+ )}
{item.addons && item.addons.length > 0 && (
- {item.addons.map((a) => `+ ${a.name}${(a.quantity || 1) > 1 ? ` ×${a.quantity}` : ''}`).join(' · ')} + {item.addons + .map((a) => `+ ${a.name}${(a.quantity || 1) > 1 ? ` ×${a.quantity}` : ''}`) + .join(' · ')}
)} {item.special_instructions && ( -
"{item.special_instructions}"
+
+ "{item.special_instructions}" +
)}
); @@ -511,27 +616,37 @@ function OrderDetailContent({ > {tOrders('voidedItemsCount', { count: inactiveItems.length })} - + )} {showVoidedItems && inactiveItems.length > 0 && canRestoreItems && (
{inactiveItems.map((cItem: OrderItem) => ( -
+
- {cItem.quantity}x {formatItemHeading(cItem.product_name, cItem.variant_selection)} + {cItem.quantity}x{' '} + {formatItemHeading(cItem.product_name, cItem.variant_selection)} - {cItem.status === 'cancelled' && !isPaid && !['completed', 'cancelled'].includes(order.status) && onRestoreItem && ( - - )} + {cItem.status === 'cancelled' && + !isPaid && + !['completed', 'cancelled'].includes(order.status) && + onRestoreItem && ( + + )}
))}
@@ -619,8 +734,10 @@ function OrderDetailContent({
{orderPrints.map((p, idx) => (
- {idx + 1}. {tOrders('printHistoryEntry', { - printedType: p.print_type === 'reprint' ? tOrders('reprint') : tOrders('printed'), + {idx + 1}.{' '} + {tOrders('printHistoryEntry', { + printedType: + p.print_type === 'reprint' ? tOrders('reprint') : tOrders('printed'), user: p.user_name, time: formatDateTime(p.printed_at), })} @@ -641,17 +758,29 @@ function OrderDetailContent({ ) : hasUnpaidSplitBills ? (
{splitBills.map((splitBill) => ( -
+

{splitBill.split_label || `#${splitBill.bill_number}`}

- {fmt(Number(splitBill.payment_status === 'paid' ? splitBill.total : splitBill.balance))} · {splitBill.payment_status === 'paid' ? tOrders('paid') : tOrders('balance')} + {fmt( + Number( + splitBill.payment_status === 'paid' ? splitBill.total : splitBill.balance, + ), + )}{' '} + · {splitBill.payment_status === 'paid' ? tOrders('paid') : tOrders('balance')}

{canPaySplitBill(splitBill) && onPayBill && ( - )} @@ -677,9 +806,11 @@ function OrderDetailContent({ disabled={printingBillId === bill.id} className="flex-1 h-10 border-border text-foreground hover:bg-muted font-semibold text-xs" > - {printingBillId === bill.id - ? - : } + {printingBillId === bill.id ? ( + + ) : ( + + )} {printCount > 0 ? tCommon('reprint') : tCommon('print')} )} @@ -690,9 +821,11 @@ function OrderDetailContent({ disabled={sendingWaOrderId === order.id} className="flex-1 h-10 border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950/40 font-semibold text-xs" > - {sendingWaOrderId === order.id - ? - : } + {sendingWaOrderId === order.id ? ( + + ) : ( + + )} {tOrders('sendReceipt')} )} @@ -739,9 +872,11 @@ function OrderDetailContent({ disabled={printingSlipOrderId === order.id} className="mt-2 w-full h-10 border-border text-foreground hover:bg-muted font-semibold text-xs" > - {printingSlipOrderId === order.id - ? - : } + {printingSlipOrderId === order.id ? ( + + ) : ( + + )} {tOrders('printDeliverySlip')} )} diff --git a/main/routes/orders.ts b/main/routes/orders.ts index 3730cc1ba..c6c7b3d42 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -2130,7 +2130,16 @@ router.patch( WHERE b.order_id = ? ORDER BY b.id ASC `) - .all(req.params.id) as any[]; + .all(req.params.id) as Array<{ + id: string; + total?: number | null; + round_off?: number | null; + paid_amount?: number | null; + refunded_cents?: number | null; + balance?: number | null; + payment_details?: unknown; + payment_status?: string | null; + }>; const evaluatedBills = billRows.map((b) => { const totalMinor = Math.max(0, Math.round(Number(b.total || 0) * minorFactor)); @@ -2143,7 +2152,7 @@ router.patch( 0, Math.round(Number(b.balance ?? fallbackBalance) * minorFactor), ); - let parsedPaymentDetails: any[] = []; + let parsedPaymentDetails: Array<{ amount?: unknown }> = []; if (typeof b.payment_details === 'string' && b.payment_details.trim()) { try { const parsed = JSON.parse(b.payment_details); @@ -2152,7 +2161,7 @@ router.patch( parsedPaymentDetails = []; } } else if (Array.isArray(b.payment_details)) { - parsedPaymentDetails = b.payment_details; + parsedPaymentDetails = b.payment_details as Array<{ amount?: unknown }>; } const detailsPaidMinor = parsedPaymentDetails.reduce( (sum, entry) => sum + Math.max(0, Math.round(Number(entry?.amount || 0) * minorFactor)), @@ -2177,7 +2186,9 @@ router.patch( : 0; const isSettled = !isPayable || - (paymentStatus === 'paid' && unpaidBalanceMinor === 0 && recordedPaidMinor >= totalMinor); + (paymentStatus === 'paid' && + unpaidBalanceMinor === 0 && + recordedPaidMinor >= totalMinor); return { id: b.id, paymentStatus, @@ -2296,14 +2307,17 @@ router.patch( }); } + type PinApproverCandidate = { id: string; role: string; pin_hash: string }; const explicitApproverId = req.body.approver_id || req.body.manager_id; const candidateUsers = explicitApproverId ? (db - .prepare('SELECT * FROM users WHERE id = ? AND is_active = 1 AND pin_hash IS NOT NULL') - .all(explicitApproverId) as any[]) + .prepare( + 'SELECT * FROM users WHERE id = ? AND is_active = 1 AND pin_hash IS NOT NULL', + ) + .all(explicitApproverId) as PinApproverCandidate[]) : (db .prepare('SELECT * FROM users WHERE is_active = 1 AND pin_hash IS NOT NULL') - .all() as any[]); + .all() as PinApproverCandidate[]); const user = candidateUsers.find( (candidate) => diff --git a/tests/addon-inventory-lifecycle.test.ts b/tests/addon-inventory-lifecycle.test.ts index 5e471f2a7..2eb6a8272 100644 --- a/tests/addon-inventory-lifecycle.test.ts +++ b/tests/addon-inventory-lifecycle.test.ts @@ -14,14 +14,30 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { }; const { - initTestDb, createApp, startServer, seedOwnerUser, seedManagerUser, seedCategory, seedProduct, - api, assertEqualOrThrow, assertIncludesOrThrow, assertOrThrow, - getResults, resetCounters, closeDatabase, now, + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + api, + assertEqualOrThrow, + assertIncludesOrThrow, + assertOrThrow, + getResults, + resetCounters, + closeDatabase, + now, } = require('./helpers/test-setup'); const { MIGRATIONS, validateInventoryLedgerDatabase } = require('../main/db'); const { addonGroupRoutes } = require('../main/routes/addon-groups'); const { orderRoutes } = require('../main/routes/orders'); -const { isAddonSoldOut, isAddonLowStock, addonStockCeiling } = require('../frontend/src/lib/addon-inventory'); +const { + isAddonSoldOut, + isAddonLowStock, + addonStockCeiling, +} = require('../frontend/src/lib/addon-inventory'); /** Version the add-on inventory migration ships as. */ const ADDON_MIGRATION_VERSION = 104; @@ -33,7 +49,10 @@ const OAT_THRESHOLD = 8; /** Stock a pool holds right now. */ const stockOf = (db: any, addonId: string) => - Number((db.prepare('SELECT stock_quantity FROM addons WHERE id = ?').get(addonId) as any)?.stock_quantity ?? 0); + Number( + (db.prepare('SELECT stock_quantity FROM addons WHERE id = ?').get(addonId) as any) + ?.stock_quantity ?? 0, + ); /** Every ledger row an add-on pool has accumulated, oldest first. */ const movementsFor = (db: any, addonId: string): any[] => @@ -41,10 +60,16 @@ const movementsFor = (db: any, addonId: string): any[] => /** What a line recorded it took, per add-on row. */ const snapshotOf = (db: any, orderItemId: any): any[] => - db.prepare('SELECT addon_id, quantity, inventory_deducted_quantity FROM order_item_addons WHERE order_item_id = ? ORDER BY id') + db + .prepare( + 'SELECT addon_id, quantity, inventory_deducted_quantity FROM order_item_addons WHERE order_item_id = ? ORDER BY id', + ) .all(orderItemId); -function columnsOf(db: any, table: string): { name: string; notnull: number; dflt_value: unknown }[] { +function columnsOf( + db: any, + table: string, +): { name: string; notnull: number; dflt_value: unknown }[] { return db.prepare(`PRAGMA table_info(${table})`).all(); } @@ -58,15 +83,25 @@ async function main() { const manager = seedManagerUser(db); seedCategory(db, 'cat-lifecycle', 'Lifecycle'); - seedProduct(db, 'prod-burger', 'cat-lifecycle', 'Burger', 100, { tax_type: 'none', stock_quantity: 0 }); - seedProduct(db, 'prod-wrap', 'cat-lifecycle', 'Wrap', 100, { tax_type: 'none', stock_quantity: 0 }); + seedProduct(db, 'prod-burger', 'cat-lifecycle', 'Burger', 100, { + tax_type: 'none', + stock_quantity: 0, + }); + seedProduct(db, 'prod-wrap', 'cat-lifecycle', 'Wrap', 100, { + tax_type: 'none', + stock_quantity: 0, + }); const app = createApp({ '/api/addon-groups': addonGroupRoutes, '/api/orders': orderRoutes }); const { baseUrl, server } = await startServer(app); const createOrder = (items: any[], headers: Record = owner.authHeader) => api(baseUrl, '/api/orders', { method: 'POST', headers, body: { type: 'takeaway', items } }); - const burgerWith = (quantity: number, addons: any[]) => ({ product_id: 'prod-burger', quantity, addons }); + const burgerWith = (quantity: number, addons: any[]) => ({ + product_id: 'prod-burger', + quantity, + addons, + }); const countOrders = () => Number((db.prepare('SELECT COUNT(*) AS n FROM orders').get() as any).n); let groupId: string; @@ -76,21 +111,42 @@ async function main() { { const migration = MIGRATIONS.find((m: any) => m.version === ADDON_MIGRATION_VERSION); assertOrThrow(!!migration, 'the add-on inventory migration is registered'); - assertEqualOrThrow(migration.name, 'add_addon_inventory', 'the migration is add_addon_inventory'); + assertEqualOrThrow( + migration.name, + 'add_addon_inventory', + 'the migration is add_addon_inventory', + ); const addonColumns = columnsOf(db, 'addons').map((column) => column.name); for (const column of ['track_inventory', 'stock_quantity', 'low_stock_threshold']) { assertOrThrow(addonColumns.includes(column), `addons.${column} exists`); } - const snapshotColumn = columnsOf(db, 'order_item_addons').find((c) => c.name === 'inventory_deducted_quantity'); + const snapshotColumn = columnsOf(db, 'order_item_addons').find( + (c) => c.name === 'inventory_deducted_quantity', + ); assertOrThrow(snapshotColumn, 'order_item_addons.inventory_deducted_quantity exists'); - assertEqualOrThrow(snapshotColumn.notnull, 1, 'the deduction snapshot is NOT NULL, so a line cannot record nothing'); - assertEqualOrThrow(String(snapshotColumn.dflt_value), '0', 'the deduction snapshot defaults to 0 for an untracked add-on'); + assertEqualOrThrow( + snapshotColumn.notnull, + 1, + 'the deduction snapshot is NOT NULL, so a line cannot record nothing', + ); + assertEqualOrThrow( + String(snapshotColumn.dflt_value), + '0', + 'the deduction snapshot defaults to 0 for an untracked add-on', + ); const movementColumns = columnsOf(db, 'inventory_movements'); - assertOrThrow(movementColumns.some((c) => c.name === 'addon_id'), 'inventory_movements.addon_id exists'); + assertOrThrow( + movementColumns.some((c) => c.name === 'addon_id'), + 'inventory_movements.addon_id exists', + ); const productId = movementColumns.find((c) => c.name === 'product_id'); - assertEqualOrThrow(productId.notnull, 0, 'a movement may name no product, which is how an add-on pool is recorded'); + assertEqualOrThrow( + productId.notnull, + 0, + 'a movement may name no product, which is how an add-on pool is recorded', + ); } // ── 2. A tracked add-on is created with its opening stock ───────────── @@ -106,7 +162,13 @@ async function main() { max_selection: 99, allow_multiple_quantities: true, addons: [ - { name: 'Oat Milk', price: 1, track_inventory: true, stock_quantity: 40, low_stock_threshold: OAT_THRESHOLD }, + { + name: 'Oat Milk', + price: 1, + track_inventory: true, + stock_quantity: 40, + low_stock_threshold: OAT_THRESHOLD, + }, { name: 'Whole Milk', price: 0 }, ], }, @@ -120,27 +182,64 @@ async function main() { assertEqualOrThrow(oat.track_inventory, true, 'the catalog reports the add-on as tracked'); assertEqualOrThrow(oat.stock_quantity, 40, 'the catalog reports the opening stock'); assertEqualOrThrow(oat.low_stock_threshold, 8, 'the catalog reports the reorder threshold'); - assertEqualOrThrow(whole.track_inventory, false, 'an add-on created without tracking is untracked'); + assertEqualOrThrow( + whole.track_inventory, + false, + 'an add-on created without tracking is untracked', + ); assertEqualOrThrow(stockOf(db, oat.id), 40, 'the opening stock is on the pool'); - assertEqualOrThrow(stockOf(db, whole.id), 0, 'an untracked add-on starts with nothing to sell'); + assertEqualOrThrow( + stockOf(db, whole.id), + 0, + 'an untracked add-on starts with nothing to sell', + ); // Opening stock has to exist as a ledger event, or the pool is the exact // silent failure the backup validator exists to reject. const opening = movementsFor(db, oat.id); assertEqualOrThrow(opening.length, 1, 'opening stock appends one ledger movement'); - assertEqualOrThrow(opening[0].movement_type, 'adjustment', 'the opening movement is an adjustment'); - assertEqualOrThrow(opening[0].reference_type, 'opening_balance', 'the opening movement is an opening balance'); - assertEqualOrThrow(opening[0].quantity_delta, 40, 'the opening movement carries the full quantity'); - assertEqualOrThrow(opening[0].stock_after, 40, 'the opening movement records the resulting stock'); - assertEqualOrThrow(opening[0].actor_user_id, owner.userId, 'the opening movement names the operator who set it'); - assertEqualOrThrow(movementsFor(db, whole.id).length, 0, 'an untracked add-on writes no ledger row'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'a newly created pool reconciles against its ledger'); + assertEqualOrThrow( + opening[0].movement_type, + 'adjustment', + 'the opening movement is an adjustment', + ); + assertEqualOrThrow( + opening[0].reference_type, + 'opening_balance', + 'the opening movement is an opening balance', + ); + assertEqualOrThrow( + opening[0].quantity_delta, + 40, + 'the opening movement carries the full quantity', + ); + assertEqualOrThrow( + opening[0].stock_after, + 40, + 'the opening movement records the resulting stock', + ); + assertEqualOrThrow( + opening[0].actor_user_id, + owner.userId, + 'the opening movement names the operator who set it', + ); + assertEqualOrThrow( + movementsFor(db, whole.id).length, + 0, + 'an untracked add-on writes no ledger row', + ); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'a newly created pool reconciles against its ledger', + ); // The pool is only reachable from a till once the group is linked to the // products that offer it. for (const productId of ['prod-burger', 'prod-wrap']) { - db.prepare('INSERT INTO addon_group_product (addon_group_id, product_id) VALUES (?, ?)') - .run(groupId, productId); + db.prepare( + 'INSERT INTO addon_group_product (addon_group_id, product_id) VALUES (?, ?)', + ).run(groupId, productId); } } @@ -151,20 +250,44 @@ async function main() { // Three burgers with two extra oat milk each: six, not two. const res = await createOrder([burgerWith(3, [{ id: OAT, quantity: 2 }])]); assertEqualOrThrow(res.status, 201, 'the order is created'); - assertEqualOrThrow(stockOf(db, OAT), before - 6, 'the pool is debited by the multiplied requirement'); + assertEqualOrThrow( + stockOf(db, OAT), + before - 6, + 'the pool is debited by the multiplied requirement', + ); const snapshot = snapshotOf(db, res.data.order.items[0].id); assertEqualOrThrow(snapshot.length, 1, 'the line records one add-on row'); - assertEqualOrThrow(snapshot[0].quantity, 2, 'the row keeps the customer-facing add-on quantity'); - assertEqualOrThrow(snapshot[0].inventory_deducted_quantity, 6, 'the row snapshots what it actually took'); + assertEqualOrThrow( + snapshot[0].quantity, + 2, + 'the row keeps the customer-facing add-on quantity', + ); + assertEqualOrThrow( + snapshot[0].inventory_deducted_quantity, + 6, + 'the row snapshots what it actually took', + ); const sale = movementsFor(db, OAT)[1]; assertEqualOrThrow(sale.movement_type, 'sale', 'the debit is a sale movement'); - assertEqualOrThrow(sale.quantity_delta, -6, 'the ledger row carries the full multiplied debit'); + assertEqualOrThrow( + sale.quantity_delta, + -6, + 'the ledger row carries the full multiplied debit', + ); assertEqualOrThrow(sale.stock_after, before - 6, 'the ledger row records the resulting pool'); - assertEqualOrThrow(sale.actor_user_id, owner.userId, 'the debit names the authenticated operator'); + assertEqualOrThrow( + sale.actor_user_id, + owner.userId, + 'the debit names the authenticated operator', + ); assertEqualOrThrow(sale.product_id, null, 'an add-on pool movement names no product'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles after a sale'); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles after a sale', + ); } // ── 4. A pool that cannot cover the line refuses the order ──────────── @@ -172,24 +295,42 @@ async function main() { { const before = stockOf(db, OAT); const ordersBefore = countOrders(); - const perItem = Math.floor(before / 2) + 1; // two items of this each exceed the pool + const perItem = Math.floor(before / 2) + 1; // two items of this each exceed the pool const res = await createOrder([burgerWith(2, [{ id: OAT, quantity: perItem }])]); assertEqualOrThrow(res.status, 400, 'an order the pool cannot cover is refused'); assertIncludesOrThrow(res.data.error, 'Oat Milk', 'the refusal names the add-on'); - assertIncludesOrThrow(res.data.error, `requested ${perItem * 2}`, 'the refusal names the multiplied requirement'); - assertIncludesOrThrow(res.data.error, `available ${before}`, 'the refusal names what is left in the pool'); + assertIncludesOrThrow( + res.data.error, + `requested ${perItem * 2}`, + 'the refusal names the multiplied requirement', + ); + assertIncludesOrThrow( + res.data.error, + `available ${before}`, + 'the refusal names what is left in the pool', + ); assertEqualOrThrow(stockOf(db, OAT), before, 'a refused order moves no stock'); assertEqualOrThrow(countOrders(), ordersBefore, 'a refused order writes no order'); assertEqualOrThrow(movementsFor(db, OAT).length, 2, 'a refused order appends no ledger row'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles after a refusal'); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles after a refusal', + ); } { // An untracked add-on has no pool to move and must keep selling at zero. const res = await createOrder([burgerWith(1, [{ id: WHOLE, quantity: 4 }])]); assertEqualOrThrow(res.status, 201, 'an untracked add-on still orders normally'); - assertEqualOrThrow(snapshotOf(db, res.data.order.items[0].id)[0].inventory_deducted_quantity, 0, 'an untracked add-on records no deduction'); + assertEqualOrThrow( + snapshotOf(db, res.data.order.items[0].id)[0].inventory_deducted_quantity, + 0, + 'an untracked add-on records no deduction', + ); await api(baseUrl, `/api/orders/${res.data.order.id}/status`, { - method: 'PATCH', headers: owner.authHeader, body: { status: 'cancelled' }, + method: 'PATCH', + headers: owner.authHeader, + body: { status: 'cancelled' }, }); } @@ -206,43 +347,83 @@ async function main() { const movementsBefore = movementsFor(db, OAT).length; const cancelled = await api(baseUrl, `/api/orders/${res.data.order.id}/status`, { - method: 'PATCH', headers: manager.authHeader, body: { status: 'cancelled', reason: 'customer left' }, + method: 'PATCH', + headers: manager.authHeader, + body: { status: 'cancelled', reason: 'customer left' }, }); assertEqualOrThrow(cancelled.status, 200, 'the order cancels'); - assertEqualOrThrow(stockOf(db, OAT), before, 'cancelling returns exactly what the lines took'); + assertEqualOrThrow( + stockOf(db, OAT), + before, + 'cancelling returns exactly what the lines took', + ); const restores = movementsFor(db, OAT).slice(movementsBefore); assertEqualOrThrow(restores.length, 2, 'each debited line appends its own restore'); - assertEqualOrThrow(restores[0].movement_type, 'cancel_restore', 'a restore is a cancel_restore movement'); + assertEqualOrThrow( + restores[0].movement_type, + 'cancel_restore', + 'a restore is a cancel_restore movement', + ); assertEqualOrThrow(restores[0].quantity_delta, 6, 'the first line restores its own 6'); assertEqualOrThrow(restores[1].quantity_delta, 1, 'the second line restores its own 1'); - assertEqualOrThrow(restores[0].actor_user_id, manager.userId, 'the restore names the operator who cancelled'); - assertIncludesOrThrow(String(restores[0].reason), 'customer left', 'the restore carries the cancellation reason'); + assertEqualOrThrow( + restores[0].actor_user_id, + manager.userId, + 'the restore names the operator who cancelled', + ); + assertIncludesOrThrow( + String(restores[0].reason), + 'customer left', + 'the restore carries the cancellation reason', + ); await api(baseUrl, `/api/orders/${res.data.order.id}/status`, { - method: 'PATCH', headers: manager.authHeader, body: { status: 'cancelled' }, + method: 'PATCH', + headers: manager.authHeader, + body: { status: 'cancelled' }, }); - assertEqualOrThrow(stockOf(db, OAT), before, 're-cancelling does not hand the pool a second time'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles after a cancel'); + assertEqualOrThrow( + stockOf(db, OAT), + before, + 're-cancelling does not hand the pool a second time', + ); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles after a cancel', + ); } // ── 6. A cancelled line returns its stock; a voided line does not ────── console.log('\n--- A cancelled line returns its stock; a voided line does not ---'); { const cancelledBefore = stockOf(db, OAT); - const cancellable = (await createOrder([ - burgerWith(2, [{ id: OAT, quantity: 3 }]), - { product_id: 'prod-wrap', quantity: 1 }, - ])).data.order; + const cancellable = ( + await createOrder([ + burgerWith(2, [{ id: OAT, quantity: 3 }]), + { product_id: 'prod-wrap', quantity: 1 }, + ]) + ).data.order; const cancellableItem = cancellable.items[0].id; assertEqualOrThrow(stockOf(db, OAT), cancelledBefore - 6, 'the pending line debited 6'); - const cancelled = await api(baseUrl, `/api/orders/${cancellable.id}/items/${cancellableItem}/cancel`, { - method: 'PATCH', headers: manager.authHeader, body: { reason: 'sent back' }, - }); + const cancelled = await api( + baseUrl, + `/api/orders/${cancellable.id}/items/${cancellableItem}/cancel`, + { + method: 'PATCH', + headers: manager.authHeader, + body: { reason: 'sent back' }, + }, + ); assertEqualOrThrow(cancelled.status, 200, 'a pending line cancels'); assertEqualOrThrow(stockOf(db, OAT), cancelledBefore, 'cancelling the line returns its 6'); - assertEqualOrThrow(snapshotOf(db, cancellableItem)[0].inventory_deducted_quantity, 6, 'the cancelled line keeps its record of what it took'); + assertEqualOrThrow( + snapshotOf(db, cancellableItem)[0].inventory_deducted_quantity, + 6, + 'the cancelled line keeps its record of what it took', + ); // A void is kitchen waste: the milk was poured, so the stock stays gone. const voidBefore = stockOf(db, OAT); @@ -251,45 +432,96 @@ async function main() { db.prepare("UPDATE order_items SET status = 'preparing' WHERE id = ?").run(voidedItem); const movementsBefore = movementsFor(db, OAT).length; - const voidedResponse = await api(baseUrl, `/api/orders/${voided.id}/items/${voidedItem}/cancel`, { - method: 'PATCH', headers: owner.authHeader, body: { override_pin: '1234' }, - }); + const voidedResponse = await api( + baseUrl, + `/api/orders/${voided.id}/items/${voidedItem}/cancel`, + { + method: 'PATCH', + headers: owner.authHeader, + body: { override_pin: '1234' }, + }, + ); assertEqualOrThrow(voidedResponse.status, 200, 'the in-progress line voids'); - assertEqualOrThrow((db.prepare('SELECT status FROM order_items WHERE id = ?').get(voidedItem) as any).status, 'voided', 'the line is marked voided'); - assertEqualOrThrow(stockOf(db, OAT), voidBefore - 6, 'voiding leaves the add-on stock consumed'); - assertEqualOrThrow(movementsFor(db, OAT).length, movementsBefore, 'a void appends no add-on movement at all'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles after a void'); + assertEqualOrThrow( + (db.prepare('SELECT status FROM order_items WHERE id = ?').get(voidedItem) as any).status, + 'voided', + 'the line is marked voided', + ); + assertEqualOrThrow( + stockOf(db, OAT), + voidBefore - 6, + 'voiding leaves the add-on stock consumed', + ); + assertEqualOrThrow( + movementsFor(db, OAT).length, + movementsBefore, + 'a void appends no add-on movement at all', + ); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles after a void', + ); // Whole-order cancellation restores add-on stock only for pending lines; prepared lines use waste semantics. const mixedBefore = stockOf(db, OAT); - const mixedOrder = (await createOrder([ - burgerWith(1, [{ id: OAT, quantity: 2 }]), - { product_id: 'prod-wrap', quantity: 1, addons: [{ id: OAT, quantity: 3 }] }, - ])).data.order; + const mixedOrder = ( + await createOrder([ + burgerWith(1, [{ id: OAT, quantity: 2 }]), + { product_id: 'prod-wrap', quantity: 1, addons: [{ id: OAT, quantity: 3 }] }, + ]) + ).data.order; assertEqualOrThrow(stockOf(db, OAT), mixedBefore - 5, 'mixed order debits 2 and 3'); - db.prepare("UPDATE order_items SET status = 'preparing' WHERE id = ?").run(mixedOrder.items[0].id); + db.prepare("UPDATE order_items SET status = 'preparing' WHERE id = ?").run( + mixedOrder.items[0].id, + ); const mixedCancel = await api(baseUrl, `/api/orders/${mixedOrder.id}/status`, { - method: 'PATCH', headers: manager.authHeader, body: { status: 'cancelled', override_pin: '1234', reason: 'cancelled mid-prep' }, + method: 'PATCH', + headers: manager.authHeader, + body: { status: 'cancelled', override_pin: '1234', reason: 'cancelled mid-prep' }, }); assertEqualOrThrow(mixedCancel.status, 200, 'mixed order cancels with manager PIN'); - assertEqualOrThrow(stockOf(db, OAT), mixedBefore - 2, 'only the pending line (3) restores add-on stock; the preparing line (2) stays consumed'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles after mixed whole-order cancel'); + assertEqualOrThrow( + stockOf(db, OAT), + mixedBefore - 2, + 'only the pending line (3) restores add-on stock; the preparing line (2) stays consumed', + ); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles after mixed whole-order cancel', + ); } // ── 7. Restoring a line re-deducts what it recorded ─────────────────── console.log('\n--- Restoring a line re-deducts what it recorded ---'); { - const live = (await createOrder([ - burgerWith(2, [{ id: OAT, quantity: 3 }]), - { product_id: 'prod-wrap', quantity: 1 }, - ])).data.order; - const itemId = (db.prepare('SELECT order_item_id FROM order_item_addons WHERE addon_id = ? ORDER BY id DESC LIMIT 1') - .get(OAT) as any).order_item_id; + const live = ( + await createOrder([ + burgerWith(2, [{ id: OAT, quantity: 3 }]), + { product_id: 'prod-wrap', quantity: 1 }, + ]) + ).data.order; + const itemId = ( + db + .prepare( + 'SELECT order_item_id FROM order_item_addons WHERE addon_id = ? ORDER BY id DESC LIMIT 1', + ) + .get(OAT) as any + ).order_item_id; const before = stockOf(db, OAT); - assertEqualOrThrow((await api(baseUrl, `/api/orders/${live.id}/items/${itemId}/cancel`, { - method: 'PATCH', headers: manager.authHeader, body: {}, - })).status, 200, 'the live line cancels'); + assertEqualOrThrow( + ( + await api(baseUrl, `/api/orders/${live.id}/items/${itemId}/cancel`, { + method: 'PATCH', + headers: manager.authHeader, + body: {}, + }) + ).status, + 200, + 'the live line cancels', + ); assertEqualOrThrow(stockOf(db, OAT), before + 6, 'cancelling returned the 6 it took'); // The merchant restocks through the catalog while the line sits @@ -297,50 +529,111 @@ async function main() { // move the recorded 6, never a figure recomputed from the catalog. const movementsBeforeRestock = movementsFor(db, OAT).length; await api(baseUrl, `/api/addon-groups/${groupId}/addons/${OAT}`, { - method: 'PUT', headers: owner.authHeader, body: { stock_quantity: 100 }, + method: 'PUT', + headers: owner.authHeader, + body: { stock_quantity: 100 }, }); const restocked = stockOf(db, OAT); assertEqualOrThrow(restocked, 100, 'the restock lands on the catalog API'); - assertEqualOrThrow(movementsFor(db, OAT).length, movementsBeforeRestock + 1, 'the restock is a ledger event, not a silent overwrite'); + assertEqualOrThrow( + movementsFor(db, OAT).length, + movementsBeforeRestock + 1, + 'the restock is a ledger event, not a silent overwrite', + ); const restored = await api(baseUrl, `/api/orders/${live.id}/items/${itemId}/restore`, { - method: 'PATCH', headers: manager.authHeader, body: {}, + method: 'PATCH', + headers: manager.authHeader, + body: {}, }); assertEqualOrThrow(restored.status, 200, 'the cancelled line restores'); - assertEqualOrThrow((db.prepare('SELECT status FROM order_items WHERE id = ?').get(itemId) as any).status, 'pending', 'the restored line is active again'); - assertEqualOrThrow(stockOf(db, OAT), restocked - 6, 'restore re-deducts the recorded snapshot, not a recomputation'); - assertEqualOrThrow(snapshotOf(db, itemId)[0].inventory_deducted_quantity, 6, 'the snapshot row is unchanged by the restore'); - - assertEqualOrThrow((await api(baseUrl, `/api/orders/${live.id}/items/${itemId}/restore`, { - method: 'PATCH', headers: manager.authHeader, body: {}, - })).status, 200, 'restoring an already-active line is accepted'); - assertEqualOrThrow(stockOf(db, OAT), restocked - 6, 'restoring an already-active line moves no stock'); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles after a restore'); + assertEqualOrThrow( + (db.prepare('SELECT status FROM order_items WHERE id = ?').get(itemId) as any).status, + 'pending', + 'the restored line is active again', + ); + assertEqualOrThrow( + stockOf(db, OAT), + restocked - 6, + 'restore re-deducts the recorded snapshot, not a recomputation', + ); + assertEqualOrThrow( + snapshotOf(db, itemId)[0].inventory_deducted_quantity, + 6, + 'the snapshot row is unchanged by the restore', + ); + + assertEqualOrThrow( + ( + await api(baseUrl, `/api/orders/${live.id}/items/${itemId}/restore`, { + method: 'PATCH', + headers: manager.authHeader, + body: {}, + }) + ).status, + 200, + 'restoring an already-active line is accepted', + ); + assertEqualOrThrow( + stockOf(db, OAT), + restocked - 6, + 'restoring an already-active line moves no stock', + ); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles after a restore', + ); } // ── 8. The POS refuses to sell what the pool has emptied ─────────────── console.log('\n--- The POS refuses to sell what the pool has emptied ---'); { - const served = await api(baseUrl, `/api/addon-groups/${groupId}`, { headers: owner.authHeader }); + const served = await api(baseUrl, `/api/addon-groups/${groupId}`, { + headers: owner.authHeader, + }); const whole = served.data.addon_group.addons.find((a: any) => a.id === WHOLE); // Rake the pool down to just under its reorder threshold: the state a // kitchen actually sees before it runs out of anything. await api(baseUrl, `/api/addon-groups/${groupId}/addons/${OAT}`, { - method: 'PUT', headers: owner.authHeader, body: { stock_quantity: OAT_THRESHOLD - 1 }, + method: 'PUT', + headers: owner.authHeader, + body: { stock_quantity: OAT_THRESHOLD - 1 }, }); - const low = (await api(baseUrl, `/api/addon-groups/${groupId}`, { headers: owner.authHeader })) - .data.addon_group.addons.find((a: any) => a.id === OAT); - assertEqualOrThrow(isAddonSoldOut(low), false, 'a tracked add-on with stock left still sells'); - assertEqualOrThrow(isAddonLowStock(low), true, 'a pool below its threshold warns the cashier'); - assertEqualOrThrow(addonStockCeiling(low), OAT_THRESHOLD - 1, 'the cashier cannot dial beyond the pool'); - assertEqualOrThrow(served.data.addon_group.addons.find((a: any) => a.id === OAT).low_stock_threshold, OAT_THRESHOLD, 'the reorder threshold the POS reads is the one the catalog stores'); + const low = ( + await api(baseUrl, `/api/addon-groups/${groupId}`, { headers: owner.authHeader }) + ).data.addon_group.addons.find((a: any) => a.id === OAT); + assertEqualOrThrow( + isAddonSoldOut(low), + false, + 'a tracked add-on with stock left still sells', + ); + assertEqualOrThrow( + isAddonLowStock(low), + true, + 'a pool below its threshold warns the cashier', + ); + assertEqualOrThrow( + addonStockCeiling(low), + OAT_THRESHOLD - 1, + 'the cashier cannot dial beyond the pool', + ); + assertEqualOrThrow( + served.data.addon_group.addons.find((a: any) => a.id === OAT).low_stock_threshold, + OAT_THRESHOLD, + 'the reorder threshold the POS reads is the one the catalog stores', + ); // An untracked add-on keeps selling at zero, exactly as an untracked // product does, so switching tracking off never blocks a sale. assertEqualOrThrow(isAddonSoldOut(whole), false, 'an untracked add-on at zero still sells'); assertEqualOrThrow(isAddonLowStock(whole), false, 'an untracked add-on never warns'); - assertEqualOrThrow(addonStockCeiling(whole), null, 'an untracked add-on has no quantity ceiling'); + assertEqualOrThrow( + addonStockCeiling(whole), + null, + 'an untracked add-on has no quantity ceiling', + ); // Drain the pool through the real order path and the POS must catch up. const drainable = stockOf(db, OAT); @@ -348,17 +641,26 @@ async function main() { assertEqualOrThrow(drain.status, 201, 'the pool is emptied through a real sale'); assertEqualOrThrow(stockOf(db, OAT), 0, 'the pool lands on zero, not below it'); - const emptied = (await api(baseUrl, `/api/addon-groups/${groupId}`, { headers: owner.authHeader })) - .data.addon_group.addons.find((a: any) => a.id === OAT); + const emptied = ( + await api(baseUrl, `/api/addon-groups/${groupId}`, { headers: owner.authHeader }) + ).data.addon_group.addons.find((a: any) => a.id === OAT); assertEqualOrThrow(isAddonSoldOut(emptied), true, 'an emptied tracked add-on is sold out'); - assertEqualOrThrow(isAddonLowStock(emptied), false, 'a sold-out add-on does not also warn, so it never carries both badges'); + assertEqualOrThrow( + isAddonLowStock(emptied), + false, + 'a sold-out add-on does not also warn, so it never carries both badges', + ); assertEqualOrThrow(addonStockCeiling(emptied), 0, 'a sold-out add-on has no headroom'); assertIncludesOrThrow( String((await createOrder([burgerWith(1, [{ id: OAT, quantity: 1 }])])).data.error), 'out of stock', 'the backend refuses the sale the POS would have blocked', ); - assertEqualOrThrow(validateInventoryLedgerDatabase(db), null, 'the pool reconciles at the end of the lifecycle'); + assertEqualOrThrow( + validateInventoryLedgerDatabase(db), + null, + 'the pool reconciles at the end of the lifecycle', + ); } } finally { server.close(); @@ -371,4 +673,7 @@ async function main() { process.exit(results.failed > 0 ? 1 : 0); } -main().catch((error: any) => { console.error(error); process.exit(1); }); \ No newline at end of file +main().catch((error: any) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/cancel-override.test.ts b/tests/cancel-override.test.ts index 5b74a5927..225291554 100644 --- a/tests/cancel-override.test.ts +++ b/tests/cancel-override.test.ts @@ -68,7 +68,9 @@ function assertEqual(actual: any, expected: any, message: string) { console.log(` ✓ ${message}`); } else { failed++; - console.error(` ✗ ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + console.error( + ` ✗ ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); } } @@ -83,7 +85,7 @@ async function listen(app: any): Promise { async function request( baseUrl: string, urlPath: string, - options: Record = {} + options: Record = {}, ): Promise<{ status: number; data: any }> { const fetchOptions: any = { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, @@ -113,14 +115,16 @@ function seedTestData() { const db = getDatabase(); // Regional settings come from signup, never a fallback; seed one // explicitly so resolveRegionalSnapshot() resolves. - db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES ('country', 'IN', ?) ON CONFLICT(key) DO UPDATE SET value='IN', updated_at=excluded.updated_at`).run(now()); + db.prepare( + `INSERT INTO settings (key, value, updated_at) VALUES ('country', 'IN', ?) ON CONFLICT(key) DO UPDATE SET value='IN', updated_at=excluded.updated_at`, + ).run(now()); // Create a manager user with a hashed PIN managerUserId = 'mgr-test-001'; const pinHash = bcrypt.hashSync(MANAGER_PIN, 10); db.prepare( `INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( managerUserId, 'Test Manager', @@ -130,53 +134,63 @@ function seedTestData() { pinHash, 1, now(), - now() + now(), ); // Create a product for order items + db.prepare(`INSERT INTO categories (id, name, sort_order) VALUES (?, ?, ?)`).run( + 'cat-test', + 'Test', + 1, + ); db.prepare( - `INSERT INTO categories (id, name, sort_order) VALUES (?, ?, ?)` - ).run('cat-test', 'Test', 1); - db.prepare( - `INSERT INTO products (id, category_id, name, price, is_active, sort_order) VALUES (?, ?, ?, ?, ?, ?)` + `INSERT INTO products (id, category_id, name, price, is_active, sort_order) VALUES (?, ?, ?, ?, ?, ?)`, ).run('prod-test', 'cat-test', 'Test Item', 100, 1, 1); // Create orders in various statuses const insertOrder = db.prepare( `INSERT INTO orders (order_number, table_id, type, status, subtotal, total, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ); // Pending order (no override required) insertOrder.run('ORD-TEST-001', null, 'takeaway', 'pending', 100, 100, now(), now()); - const pendingId = (db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-001') as any).id; + const pendingId = ( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-001') as any + ).id; db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(pendingId, 'prod-test', 'Test Item', 100, 1, 100, 0, 100, 'pending', now(), now()); // Preparing order (override required) insertOrder.run('ORD-TEST-002', null, 'takeaway', 'preparing', 100, 100, now(), now()); - const preparingId = (db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-002') as any).id; + const preparingId = ( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-002') as any + ).id; db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(preparingId, 'prod-test', 'Test Item', 100, 1, 100, 0, 100, 'preparing', now(), now()); // Ready order (override required) insertOrder.run('ORD-TEST-003', null, 'takeaway', 'ready', 100, 100, now(), now()); - const readyId = (db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-003') as any).id; + const readyId = ( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-003') as any + ).id; db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(readyId, 'prod-test', 'Test Item', 100, 1, 100, 0, 100, 'ready', now(), now()); // Served order (override required) insertOrder.run('ORD-TEST-004', null, 'takeaway', 'served', 100, 100, now(), now()); - const servedId = (db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-004') as any).id; + const servedId = ( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get('ORD-TEST-004') as any + ).id; db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run(servedId, 'prod-test', 'Test Item', 100, 1, 100, 0, 100, 'served', now(), now()); } @@ -205,9 +219,18 @@ async function main() { // Add auth middleware (replicate requireAuth from server.ts) app.use((req: any, res: any, next: any) => { - if (!req.path.startsWith('/api')) { next(); return; } - if (req.path === '/api/health') { next(); return; } - if (req.path.startsWith('/api/auth')) { next(); return; } + if (!req.path.startsWith('/api')) { + next(); + return; + } + if (req.path === '/api/health') { + next(); + return; + } + if (req.path.startsWith('/api/auth')) { + next(); + return; + } const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { @@ -232,7 +255,7 @@ async function main() { const token = jwt.sign( { userId: managerUserId, email: 'mgr@test.local', role: 'manager' }, getJWTSecret(), - { expiresIn: '1h' } + { expiresIn: '1h' }, ); const authHeader = `Bearer ${token}`; @@ -242,7 +265,9 @@ async function main() { { // Get the pending order ID const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-001'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-001'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, @@ -257,7 +282,9 @@ async function main() { console.log('\n2. Cancel preparing order without PIN (should fail)'); { const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-002'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-002'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, @@ -271,7 +298,9 @@ async function main() { console.log('\n3. Cancel preparing order with wrong PIN (should fail)'); { const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-002'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-002'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, @@ -285,11 +314,17 @@ async function main() { console.log('\n4. Cancel preparing order with valid PIN'); { const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-002'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-002'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, - body: JSON.stringify({ status: 'cancelled', override_pin: MANAGER_PIN, reason: 'Item out of stock' }), + body: JSON.stringify({ + status: 'cancelled', + override_pin: MANAGER_PIN, + reason: 'Item out of stock', + }), }); assertEqual(res.status, 200, 'returns 200 for cancelling with valid PIN'); assertEqual(res.data.order.status, 'cancelled', 'preparing order status becomes cancelled'); @@ -299,7 +334,9 @@ async function main() { console.log('\n5. Cancel ready order with valid PIN'); { const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-003'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-003'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, @@ -313,7 +350,9 @@ async function main() { console.log('\n6. Cancel served order with valid PIN'); { const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-004'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-004'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, @@ -338,7 +377,9 @@ async function main() { console.log('\n8. Invalid status value'); { const db = getDatabase(); - const order = db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-001'").get() as any; + const order = db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-001'") + .get() as any; const res = await request(baseUrl, `/api/orders/${order.id}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, @@ -354,90 +395,169 @@ async function main() { const db = getDatabase(); db.prepare( `INSERT INTO orders (order_number, table_id, type, status, subtotal, total, created_at, updated_at) - VALUES ('ORD-TEST-PAID-PENDING', null, 'takeaway', 'pending', 100, 100, ?, ?)` + VALUES ('ORD-TEST-PAID-PENDING', null, 'takeaway', 'pending', 100, 100, ?, ?)`, ).run(now(), now()); - const paidPendingId = (db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-PAID-PENDING'").get() as any).id; + const paidPendingId = ( + db + .prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-PAID-PENDING'") + .get() as any + ).id; db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, 'prod-test', 'Test Item', 100, 1, 100, 0, 100, 'pending', ?, ?)` + VALUES (?, 'prod-test', 'Test Item', 100, 1, 100, 0, 100, 'pending', ?, ?)`, ).run(paidPendingId, now(), now()); db.prepare( `INSERT INTO bills (bill_number, order_id, subtotal, tax_amount, discount_amount, total, payment_status, paid_amount, balance, payment_details, created_at, updated_at) - VALUES ('BILL-PAID-PENDING', ?, 100, 0, 0, 100, 'paid', 100, 0, ?, ?, ?)` + VALUES ('BILL-PAID-PENDING', ?, 100, 0, 0, 100, 'paid', 100, 0, ?, ?, ?)`, ).run(paidPendingId, JSON.stringify([{ method: 'cash', amount: 100 }]), now(), now()); - const paidBillId = (db.prepare("SELECT id FROM bills WHERE bill_number = 'BILL-PAID-PENDING'").get() as any).id; + const paidBillId = ( + db.prepare("SELECT id FROM bills WHERE bill_number = 'BILL-PAID-PENDING'").get() as any + ).id; const noPinRes = await request(baseUrl, `/api/orders/${paidPendingId}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, body: JSON.stringify({ status: 'cancelled', reason: 'Guest left' }), }); - assertEqual(noPinRes.status, 400, 'pending order with collected funds rejects cancellation without PIN'); - assert(String(noPinRes.data.error).includes('Manager PIN'), 'collected-funds rejection mentions Manager PIN'); + assertEqual( + noPinRes.status, + 400, + 'pending order with collected funds rejects cancellation without PIN', + ); + assert( + String(noPinRes.data.error).includes('Manager PIN'), + 'collected-funds rejection mentions Manager PIN', + ); const wrongPinRes = await request(baseUrl, `/api/orders/${paidPendingId}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, body: JSON.stringify({ status: 'cancelled', override_pin: WRONG_PIN }), }); - assertEqual(wrongPinRes.status, 403, 'pending order with collected funds rejects wrong PIN with 403'); + assertEqual( + wrongPinRes.status, + 403, + 'pending order with collected funds rejects wrong PIN with 403', + ); const validPinRes = await request(baseUrl, `/api/orders/${paidPendingId}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, - body: JSON.stringify({ status: 'cancelled', override_pin: MANAGER_PIN, reason: 'Guest left' }), + body: JSON.stringify({ + status: 'cancelled', + override_pin: MANAGER_PIN, + reason: 'Guest left', + }), }); - assertEqual(validPinRes.status, 200, 'pending order with collected funds cancels with valid manager PIN'); - assertEqual(validPinRes.data.order.status, 'cancelled', 'order status becomes cancelled after PIN approval'); - - const billAfterCancel = db.prepare('SELECT payment_status, paid_amount, balance, payment_details FROM bills WHERE id = ?').get(paidBillId) as any; - assertEqual(billAfterCancel.payment_status, 'paid', 'cancellation does not erase paid bill status'); - assertEqual(Number(billAfterCancel.paid_amount), 100, 'cancellation preserves collected paid_amount'); - const refundCount = (db.prepare('SELECT COUNT(*) as c FROM refunds WHERE bill_id = ?').get(paidBillId) as any).c; + assertEqual( + validPinRes.status, + 200, + 'pending order with collected funds cancels with valid manager PIN', + ); + assertEqual( + validPinRes.data.order.status, + 'cancelled', + 'order status becomes cancelled after PIN approval', + ); + + const billAfterCancel = db + .prepare( + 'SELECT payment_status, paid_amount, balance, payment_details FROM bills WHERE id = ?', + ) + .get(paidBillId) as any; + assertEqual( + billAfterCancel.payment_status, + 'paid', + 'cancellation does not erase paid bill status', + ); + assertEqual( + Number(billAfterCancel.paid_amount), + 100, + 'cancellation preserves collected paid_amount', + ); + const refundCount = ( + db.prepare('SELECT COUNT(*) as c FROM refunds WHERE bill_id = ?').get(paidBillId) as any + ).c; assertEqual(refundCount, 0, 'cancellation does not automatically create a refund record'); - const cancelAudit = db.prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed' ORDER BY id DESC LIMIT 1`).get(paidPendingId) as any; + const cancelAudit = db + .prepare( + `SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed' ORDER BY id DESC LIMIT 1`, + ) + .get(paidPendingId) as any; const auditDetails = JSON.parse(cancelAudit?.details_json || '{}'); assertEqual(cancelAudit?.actor_user_id, managerUserId, 'cancel audit records actor identity'); - assertEqual(auditDetails.approved_by, managerUserId, 'cancel audit records approver identity'); - assert(Array.isArray(auditDetails.overridden_safeguards) && auditDetails.overridden_safeguards.includes('collected_funds'), 'cancel audit records collected_funds safeguard'); - assert(Array.isArray(auditDetails.bill_ids) && auditDetails.bill_ids.includes(paidBillId), 'cancel audit records bill IDs'); - assertEqual(Number(auditDetails.collected_amount), 100, 'cancel audit records collected amount'); - assert(!String(cancelAudit?.details_json).includes(MANAGER_PIN), 'cancel audit never records the PIN'); + assertEqual( + auditDetails.approved_by, + managerUserId, + 'cancel audit records approver identity', + ); + assert( + Array.isArray(auditDetails.overridden_safeguards) && + auditDetails.overridden_safeguards.includes('collected_funds'), + 'cancel audit records collected_funds safeguard', + ); + assert( + Array.isArray(auditDetails.bill_ids) && auditDetails.bill_ids.includes(paidBillId), + 'cancel audit records bill IDs', + ); + assertEqual( + Number(auditDetails.collected_amount), + 100, + 'cancel audit records collected amount', + ); + assert( + !String(cancelAudit?.details_json).includes(MANAGER_PIN), + 'cancel audit never records the PIN', + ); } // ── Test 10: Pending order with only pending items restores only pending inventory (#1057) ── - console.log('\n10. Mixed pending and in-progress items restore only pending stock on order cancel'); + console.log( + '\n10. Mixed pending and in-progress items restore only pending stock on order cancel', + ); { resetPinRateLimitForTests(); const db = getDatabase(); db.prepare( `INSERT INTO products (id, category_id, name, price, is_active, track_inventory, stock_quantity, sort_order) - VALUES ('prod-inv-cancel', 'cat-test', 'Tracked Cancel Item', 50, 1, 1, 6, 2)` + VALUES ('prod-inv-cancel', 'cat-test', 'Tracked Cancel Item', 50, 1, 1, 6, 2)`, ).run(); db.prepare( `INSERT INTO orders (order_number, table_id, type, status, subtotal, total, created_at, updated_at) - VALUES ('ORD-TEST-MIXED-STOCK', null, 'takeaway', 'preparing', 200, 200, ?, ?)` + VALUES ('ORD-TEST-MIXED-STOCK', null, 'takeaway', 'preparing', 200, 200, ?, ?)`, ).run(now(), now()); - const mixedOrderId = (db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-MIXED-STOCK'").get() as any).id; + const mixedOrderId = ( + db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-TEST-MIXED-STOCK'").get() as any + ).id; // 1 pending item (qty 2, deducted 2) + 1 preparing item (qty 2, deducted 2) -> current stock 6 means initial was 10 db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, inventory_deducted_quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, 'prod-inv-cancel', 'Tracked Cancel Item', 50, 2, 2, 100, 0, 100, 'pending', ?, ?)` + VALUES (?, 'prod-inv-cancel', 'Tracked Cancel Item', 50, 2, 2, 100, 0, 100, 'pending', ?, ?)`, ).run(mixedOrderId, now(), now()); db.prepare( `INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, inventory_deducted_quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, 'prod-inv-cancel', 'Tracked Cancel Item', 50, 2, 2, 100, 0, 100, 'preparing', ?, ?)` + VALUES (?, 'prod-inv-cancel', 'Tracked Cancel Item', 50, 2, 2, 100, 0, 100, 'preparing', ?, ?)`, ).run(mixedOrderId, now(), now()); const cancelMixedRes = await request(baseUrl, `/api/orders/${mixedOrderId}/status`, { method: 'PATCH', headers: { Authorization: authHeader }, - body: JSON.stringify({ status: 'cancelled', override_pin: MANAGER_PIN, reason: 'Guest walked out' }), + body: JSON.stringify({ + status: 'cancelled', + override_pin: MANAGER_PIN, + reason: 'Guest walked out', + }), }); assertEqual(cancelMixedRes.status, 200, 'cancels mixed order with valid manager PIN'); - const stockAfter = (db.prepare("SELECT stock_quantity FROM products WHERE id = 'prod-inv-cancel'").get() as any).stock_quantity; - assertEqual(stockAfter, 8, 'only the pending item (qty 2) is restored to stock; preparing item (qty 2) is not restored'); + const stockAfter = ( + db.prepare("SELECT stock_quantity FROM products WHERE id = 'prod-inv-cancel'").get() as any + ).stock_quantity; + assertEqual( + stockAfter, + 8, + 'only the pending item (qty 2) is restored to stock; preparing item (qty 2) is not restored', + ); } } finally { server.close(); diff --git a/tests/issue-252-order-lifecycle-inventory.test.ts b/tests/issue-252-order-lifecycle-inventory.test.ts index 11b20386d..3be1d22af 100644 --- a/tests/issue-252-order-lifecycle-inventory.test.ts +++ b/tests/issue-252-order-lifecycle-inventory.test.ts @@ -23,14 +23,26 @@ const path = require('path'); const { Worker } = require('worker_threads'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-issue-252-test-')); 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, assertEqual, assert, getResults, resetCounters, closeDatabase, + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + seedTable, + api, + assertEqual, + assert, + getResults, + resetCounters, + closeDatabase, } = require('./helpers/test-setup'); const { registerRoutes } = require('../main/routes/index'); @@ -39,7 +51,11 @@ const { registerRoutes } = require('../main/routes/index'); // lookup but before its transaction. Assertions remain at the public HTTP and // database-state boundaries; this only makes the transaction seam schedulable // in a single-process integration test. -function installOrderBoundaryMutation(db: any, orderId: number | string, mutate: () => void): () => void { +function installOrderBoundaryMutation( + db: any, + orderId: number | string, + mutate: () => void, +): () => void { const originalPrepare = db.prepare.bind(db); let fired = false; db.prepare = (sql: string) => { @@ -63,7 +79,11 @@ function installOrderBoundaryMutation(db: any, orderId: number | string, mutate: }; } -function installConcurrentOrderBoundaryMutation(db: any, dbPath: string, orderId: number | string): () => void { +function installConcurrentOrderBoundaryMutation( + db: any, + dbPath: string, + orderId: number | string, +): () => void { const originalPrepare = db.prepare.bind(db); let fired = false; db.prepare = (sql: string) => { @@ -77,7 +97,8 @@ function installConcurrentOrderBoundaryMutation(db: any, dbPath: string, orderId fired = true; const signal = new SharedArrayBuffer(4); const state = new Int32Array(signal); - const worker = new Worker(` + const worker = new Worker( + ` const Database = require('better-sqlite3'); const { workerData } = require('worker_threads'); const state = new Int32Array(workerData.signal); @@ -91,7 +112,9 @@ function installConcurrentOrderBoundaryMutation(db: any, dbPath: string, orderId Atomics.store(state, 0, -1); } Atomics.notify(state, 0); - `, { eval: true, workerData: { dbPath, orderId, signal } }); + `, + { eval: true, workerData: { dbPath, orderId, signal } }, + ); const waitResult = Atomics.wait(state, 0, 0, 5000); worker.terminate(); if (waitResult === 'timed-out' || Atomics.load(state, 0) !== 1) { @@ -127,9 +150,18 @@ async function main() { seedManagerUser(db); seedCategory(db, 'cat-252', 'Lifecycle Test Category'); - seedProduct(db, 'prod-track-1', 'cat-252', 'Burger', 100, { track_inventory: true, stock_quantity: 10 }); - seedProduct(db, 'prod-track-2', 'cat-252', 'Fries', 50, { track_inventory: true, stock_quantity: 10 }); - seedProduct(db, 'prod-untrack', 'cat-252', 'Water', 20, { track_inventory: false, stock_quantity: 999 }); + seedProduct(db, 'prod-track-1', 'cat-252', 'Burger', 100, { + track_inventory: true, + stock_quantity: 10, + }); + seedProduct(db, 'prod-track-2', 'cat-252', 'Fries', 50, { + track_inventory: true, + stock_quantity: 10, + }); + seedProduct(db, 'prod-untrack', 'cat-252', 'Water', 20, { + track_inventory: false, + stock_quantity: 999, + }); seedTable(db, 'tbl-252-1', 1, 4); seedTable(db, 'tbl-252-2', 2, 2); @@ -153,11 +185,17 @@ async function main() { const order1 = await api(baseUrl, '/api/orders', { method: 'POST', headers: authHeader, - body: { type: 'dine_in', table_id: 'tbl-252-1', items: [{ product_id: 'prod-track-1', quantity: 2 }] }, + body: { + type: 'dine_in', + table_id: 'tbl-252-1', + items: [{ product_id: 'prod-track-1', quantity: 2 }], + }, }); const order1Id = order1.data.order.id; - let stock1 = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + let stock1 = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; assertEqual(stock1, 8, 'Stock deducted on order creation (10 -> 8)'); // First cancel @@ -168,7 +206,9 @@ async function main() { }); assertEqual(cancel1.status, 200, 'First cancel HTTP status'); - stock1 = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + stock1 = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; assertEqual(stock1, 10, 'Stock restored on first cancellation (8 -> 10)'); // Second cancel @@ -179,7 +219,9 @@ async function main() { }); assertEqual(cancel2.status, 200, 'Second cancel HTTP status (idempotent no-op)'); - stock1 = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + stock1 = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; assertEqual(stock1, 10, 'Stock MUST remain 10 after second cancel (no double-restock)'); // ═══════════════════════════════════════════════════════════════════ @@ -191,54 +233,136 @@ async function main() { const orderFwd = await api(baseUrl, '/api/orders', { method: 'POST', headers: authHeader, - body: { type: 'dine_in', table_id: 'tbl-252-2', items: [{ product_id: 'prod-untrack', quantity: 1 }] }, + body: { + type: 'dine_in', + table_id: 'tbl-252-2', + items: [{ product_id: 'prod-untrack', quantity: 1 }], + }, }); const fwdId = orderFwd.data.order.id; - const toPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'preparing' } }); + const toPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'preparing' }, + }); assertEqual(toPrep.status, 200, 'pending -> preparing is valid'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'preparing', 'pending -> preparing persists preparing'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'preparing', + 'pending -> preparing persists preparing', + ); - const toReady = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'ready' } }); + const toReady = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'ready' }, + }); assertEqual(toReady.status, 200, 'preparing -> ready is valid'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'ready', 'preparing -> ready persists ready'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'ready', + 'preparing -> ready persists ready', + ); // Backward transition rejections: - const readyToPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'preparing' } }); + const readyToPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'preparing' }, + }); assertEqual(readyToPrep.status, 400, 'ready -> preparing rejected (backward transition)'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'ready', 'rejected ready -> preparing leaves ready state intact'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'ready', + 'rejected ready -> preparing leaves ready state intact', + ); - const toServed = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'served' } }); + const toServed = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'served' }, + }); assertEqual(toServed.status, 200, 'ready -> served is valid'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'served', 'ready -> served persists served'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'served', + 'ready -> served persists served', + ); - const servedToPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'preparing' } }); + const servedToPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'preparing' }, + }); assertEqual(servedToPrep.status, 400, 'served -> preparing rejected (backward transition)'); - const servedToReady = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'ready' } }); + const servedToReady = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'ready' }, + }); assertEqual(servedToReady.status, 400, 'served -> ready rejected (backward transition)'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'served', 'rejected served backward transitions leave served state intact'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'served', + 'rejected served backward transitions leave served state intact', + ); // Same-state idempotent request - const sameServed = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'served' } }); + const sameServed = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'served' }, + }); assertEqual(sameServed.status, 200, 'same-state served -> served is idempotent 200'); - assert(typeof sameServed.data.order.tax_breakdown !== 'string', 'same-state status response parses tax breakdown'); - assert(typeof sameServed.data.order.tax_snapshot !== 'string', 'same-state status response parses tax snapshot'); + assert( + typeof sameServed.data.order.tax_breakdown !== 'string', + 'same-state status response parses tax breakdown', + ); + assert( + typeof sameServed.data.order.tax_snapshot !== 'string', + 'same-state status response parses tax snapshot', + ); - const toComp = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'completed', override_pin: '1234' } }); + const toComp = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'completed', override_pin: '1234' }, + }); assertEqual(toComp.status, 200, 'served -> completed is valid'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'completed', 'served -> completed persists completed'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'completed', + 'served -> completed persists completed', + ); // Terminal state locks (completed & cancelled) - const compToPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'preparing' } }); + const compToPrep = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'preparing' }, + }); assertEqual(compToPrep.status, 400, 'completed -> preparing rejected'); - const compToCancel = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'cancelled', override_pin: '1234' } }); + const compToCancel = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'cancelled', override_pin: '1234' }, + }); assertEqual(compToCancel.status, 400, 'completed -> cancelled rejected even with a valid PIN'); - const sameComp = await api(baseUrl, `/api/orders/${fwdId}/status`, { method: 'PATCH', headers: authHeader, body: { status: 'completed' } }); + const sameComp = await api(baseUrl, `/api/orders/${fwdId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'completed' }, + }); assertEqual(sameComp.status, 200, 'completed -> completed is idempotent 200'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, 'completed', 'terminal transitions leave completed state intact'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(fwdId).status, + 'completed', + 'terminal transitions leave completed state intact', + ); // Cancellation supported from every active state (pending, preparing, ready, served) for (const activeState of ['pending', 'preparing', 'ready', 'served']) { @@ -249,7 +373,11 @@ async function main() { }); const aId = activeOrder.data.order.id; if (activeState !== 'pending') { - await api(baseUrl, `/api/orders/${aId}/status`, { method: 'PATCH', headers: authHeader, body: { status: activeState } }); + await api(baseUrl, `/api/orders/${aId}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: activeState }, + }); } const canRes = await api(baseUrl, `/api/orders/${aId}/status`, { method: 'PATCH', @@ -271,7 +399,9 @@ async function main() { }); const completedOrderId = completedOrder.data.order.id; const completedItemId = completedOrder.data.order.items[0].id; - const completedStockBeforeCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + const completedStockBeforeCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; const completeRes = await api(baseUrl, `/api/orders/${completedOrderId}/status`, { method: 'PATCH', @@ -280,21 +410,36 @@ async function main() { }); assertEqual(completeRes.status, 200, 'completed-order fixture reaches completed state'); - const terminalCancel = await api(baseUrl, `/api/orders/${completedOrderId}/items/${completedItemId}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: { override_pin: '1234' }, - }); + const terminalCancel = await api( + baseUrl, + `/api/orders/${completedOrderId}/items/${completedItemId}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: { override_pin: '1234' }, + }, + ); assertEqual(terminalCancel.status, 400, 'completed-order item cancellation is rejected'); - const completedState = db.prepare(` + const completedState = db + .prepare(` SELECT o.status AS order_status, i.status AS item_status FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.id = ? AND i.id = ? - `).get(completedOrderId, completedItemId); - assertEqual(completedState.order_status, 'completed', 'completed parent status is unchanged after rejected item cancel'); - assertEqual(completedState.item_status, 'served', 'completed item status is unchanged after rejected item cancel'); + `) + .get(completedOrderId, completedItemId); + assertEqual( + completedState.order_status, + 'completed', + 'completed parent status is unchanged after rejected item cancel', + ); + assertEqual( + completedState.item_status, + 'served', + 'completed item status is unchanged after rejected item cancel', + ); assertEqual( - db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity, + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1') + .stock_quantity, completedStockBeforeCancel, 'completed-order item cancellation does not restore stock', ); @@ -306,8 +451,10 @@ async function main() { }); const pinRaceOrderId = pinRaceOrder.data.order.id; const removePinRaceHook = installOrderBoundaryMutation(db, pinRaceOrderId, () => { - db.prepare("UPDATE orders SET status = 'preparing', updated_at = ? WHERE id = ?") - .run(new Date().toISOString(), pinRaceOrderId); + db.prepare("UPDATE orders SET status = 'preparing', updated_at = ? WHERE id = ?").run( + new Date().toISOString(), + pinRaceOrderId, + ); }); let pinRaceCancel; try { @@ -319,7 +466,11 @@ async function main() { } finally { removePinRaceHook(); } - assertEqual(pinRaceCancel.status, 400, 'transaction-local status requires the current cancellation PIN policy'); + assertEqual( + pinRaceCancel.status, + 400, + 'transaction-local status requires the current cancellation PIN policy', + ); assertEqual( db.prepare('SELECT status FROM orders WHERE id = ?').get(pinRaceOrderId).status, 'preparing', @@ -333,19 +484,40 @@ async function main() { }); const paidCancelOrderId = paidCancelOrder.data.order.id; const paidCancelItemId = paidCancelOrder.data.order.items[0].id; - const paidCancelStock = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + const paidCancelStock = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; db.prepare(` INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, created_at, updated_at) VALUES (?, ?, 100, 100, 25, 75, 'partial', ?, ?, ?) - `).run(`INV-252-PARTIAL-${paidCancelOrderId}`, paidCancelOrderId, JSON.stringify([{ method: 'cash', amount: 25 }]), new Date().toISOString(), new Date().toISOString()); - const paidCancel = await api(baseUrl, `/api/orders/${paidCancelOrderId}/items/${paidCancelItemId}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + `).run( + `INV-252-PARTIAL-${paidCancelOrderId}`, + paidCancelOrderId, + JSON.stringify([{ method: 'cash', amount: 25 }]), + new Date().toISOString(), + new Date().toISOString(), + ); + const paidCancel = await api( + baseUrl, + `/api/orders/${paidCancelOrderId}/items/${paidCancelItemId}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(paidCancel.status, 409, 'partially paid item cancellation is rejected'); - assertEqual(db.prepare('SELECT status FROM order_items WHERE id = ?').get(paidCancelItemId).status, 'pending', 'partially paid rejection leaves item unchanged'); - assertEqual(db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity, paidCancelStock, 'partially paid rejection leaves stock unchanged'); + assertEqual( + db.prepare('SELECT status FROM order_items WHERE id = ?').get(paidCancelItemId).status, + 'pending', + 'partially paid rejection leaves item unchanged', + ); + assertEqual( + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1') + .stock_quantity, + paidCancelStock, + 'partially paid rejection leaves stock unchanged', + ); const roleRaceOrder = await api(baseUrl, '/api/orders', { method: 'POST', @@ -353,15 +525,31 @@ async function main() { body: { type: 'takeaway', items: [{ product_id: 'prod-untrack', quantity: 1 }] }, }); const roleRaceItemId = roleRaceOrder.data.order.items[0].id; - db.prepare("UPDATE users SET role = 'cashier', updated_at = ? WHERE id = 'owner-test-001'").run(new Date().toISOString()); - const staleRoleCancel = await api(baseUrl, `/api/orders/${roleRaceOrder.data.order.id}/items/${roleRaceItemId}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); - assertEqual(staleRoleCancel.status, 403, 'demoted actor cannot cancel with a stale owner token'); - assertEqual(db.prepare('SELECT status FROM order_items WHERE id = ?').get(roleRaceItemId).status, 'pending', 'demoted actor leaves item unchanged'); - db.prepare("UPDATE users SET role = 'owner', updated_at = ? WHERE id = 'owner-test-001'").run(new Date().toISOString()); + db.prepare("UPDATE users SET role = 'cashier', updated_at = ? WHERE id = 'owner-test-001'").run( + new Date().toISOString(), + ); + const staleRoleCancel = await api( + baseUrl, + `/api/orders/${roleRaceOrder.data.order.id}/items/${roleRaceItemId}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); + assertEqual( + staleRoleCancel.status, + 403, + 'demoted actor cannot cancel with a stale owner token', + ); + assertEqual( + db.prepare('SELECT status FROM order_items WHERE id = ?').get(roleRaceItemId).status, + 'pending', + 'demoted actor leaves item unchanged', + ); + db.prepare("UPDATE users SET role = 'owner', updated_at = ? WHERE id = 'owner-test-001'").run( + new Date().toISOString(), + ); const staleTotalsOrder = await api(baseUrl, '/api/orders', { method: 'POST', @@ -376,26 +564,53 @@ async function main() { }, }); const staleTotalsOrderId = staleTotalsOrder.data.order.id; - const staleTotalsItemId = staleTotalsOrder.data.order.items.find((item: any) => item.product_id === 'prod-untrack').id; + const staleTotalsItemId = staleTotalsOrder.data.order.items.find( + (item: any) => item.product_id === 'prod-untrack', + ).id; const removeTotalsHook = installOrderBoundaryMutation(db, staleTotalsOrderId, () => { - db.prepare('UPDATE orders SET delivery_charge = ?, updated_at = ? WHERE id = ?') - .run(99, new Date().toISOString(), staleTotalsOrderId); + db.prepare('UPDATE orders SET delivery_charge = ?, updated_at = ? WHERE id = ?').run( + 99, + new Date().toISOString(), + staleTotalsOrderId, + ); }); let staleTotalsCancel; try { - staleTotalsCancel = await api(baseUrl, `/api/orders/${staleTotalsOrderId}/items/${staleTotalsItemId}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + staleTotalsCancel = await api( + baseUrl, + `/api/orders/${staleTotalsOrderId}/items/${staleTotalsItemId}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); } finally { removeTotalsHook(); } - assertEqual(staleTotalsCancel.status, 200, 'item cancellation with a boundary order update succeeds'); - const staleTotalsState = db.prepare('SELECT subtotal, delivery_charge, total FROM orders WHERE id = ?').get(staleTotalsOrderId); - assertEqual(staleTotalsState.subtotal, 50, 'recalculation uses the current active-item subtotal'); - assertEqual(staleTotalsState.delivery_charge, 99, 'recalculation preserves the current delivery charge'); - assertEqual(staleTotalsState.total, 149, 'recalculation uses the current delivery charge, not the stale outer value'); + assertEqual( + staleTotalsCancel.status, + 200, + 'item cancellation with a boundary order update succeeds', + ); + const staleTotalsState = db + .prepare('SELECT subtotal, delivery_charge, total FROM orders WHERE id = ?') + .get(staleTotalsOrderId); + assertEqual( + staleTotalsState.subtotal, + 50, + 'recalculation uses the current active-item subtotal', + ); + assertEqual( + staleTotalsState.delivery_charge, + 99, + 'recalculation preserves the current delivery charge', + ); + assertEqual( + staleTotalsState.total, + 149, + 'recalculation uses the current delivery charge, not the stale outer value', + ); // ═══════════════════════════════════════════════════════════════════ // 4. Whole-Order Cancel with Voided Item in Multi-Item Order @@ -428,7 +643,9 @@ async function main() { assertEqual(itemAStatusRes.status, 200, 'Item A moved to preparing via order-items API'); // Void Item A (in progress void requires PIN) - db.prepare("UPDATE settings SET value = '1', updated_at = ? WHERE key = 'cloud_orders_enabled'").run(new Date().toISOString()); + db.prepare( + "UPDATE settings SET value = '1', updated_at = ? WHERE key = 'cloud_orders_enabled'", + ).run(new Date().toISOString()); db.prepare('DELETE FROM cloud_sync_outbox').run(); const voidRes = await api(baseUrl, `/api/orders/${order3Id}/items/${itemA.id}/cancel`, { method: 'PATCH', @@ -439,7 +656,8 @@ async function main() { const voidOutboxCount = await waitForOutboxCount(db, 1); assertEqual(voidOutboxCount, 1, 'First void cancellation creates one cloud event'); assertEqual( - db.prepare('SELECT event_type FROM cloud_sync_outbox ORDER BY created_at LIMIT 1').get().event_type, + db.prepare('SELECT event_type FROM cloud_sync_outbox ORDER BY created_at LIMIT 1').get() + .event_type, 'order.item_voided', 'First void cancellation is classified as a void event', ); @@ -447,10 +665,16 @@ async function main() { const itemARow = db.prepare('SELECT status FROM order_items WHERE id = ?').get(itemA.id); assertEqual(itemARow.status, 'voided', 'Item A is marked voided'); - const voidAdjustCount = db.prepare("SELECT COUNT(*) as count FROM order_items WHERE order_id = ? AND status = 'void_adjustment'").get(order3Id).count; + const voidAdjustCount = db + .prepare( + "SELECT COUNT(*) as count FROM order_items WHERE order_id = ? AND status = 'void_adjustment'", + ) + .get(order3Id).count; assertEqual(voidAdjustCount, 1, 'Exactly one void_adjustment row exists'); - const stockA_afterFirstVoid = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + const stockA_afterFirstVoid = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; const repeatedVoidRes = await api(baseUrl, `/api/orders/${order3Id}/items/${itemA.id}/cancel`, { method: 'PATCH', headers: authHeader, @@ -458,7 +682,11 @@ async function main() { }); assertEqual(repeatedVoidRes.status, 200, 'Repeated void cancellation is an idempotent no-op'); assertEqual( - db.prepare("SELECT COUNT(*) as count FROM order_items WHERE order_id = ? AND status = 'void_adjustment'").get(order3Id).count, + db + .prepare( + "SELECT COUNT(*) as count FROM order_items WHERE order_id = ? AND status = 'void_adjustment'", + ) + .get(order3Id).count, 1, 'Repeated void cancellation does not add another adjustment row', ); @@ -468,13 +696,22 @@ async function main() { 'Repeated void cancellation keeps the original item voided', ); assertEqual( - db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity, + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1') + .stock_quantity, stockA_afterFirstVoid, 'Repeated void cancellation does not restore stock', ); - assertEqual(await waitForOutboxCount(db, 1), 1, 'Repeated void cancellation creates no cloud event'); assertEqual( - db.prepare("SELECT COUNT(*) AS count FROM cloud_sync_outbox WHERE event_type = 'order.item_cancelled'").get().count, + await waitForOutboxCount(db, 1), + 1, + 'Repeated void cancellation creates no cloud event', + ); + assertEqual( + db + .prepare( + "SELECT COUNT(*) AS count FROM cloud_sync_outbox WHERE event_type = 'order.item_cancelled'", + ) + .get().count, 0, 'Repeated void cancellation is not misclassified as item cancellation', ); @@ -482,8 +719,12 @@ async function main() { const itemBRow = db.prepare('SELECT status FROM order_items WHERE id = ?').get(itemB.id); assertEqual(itemBRow.status, 'pending', 'Item B remains active (pending)'); - const stockA_beforeCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - const stockB_beforeCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-2').stock_quantity; + const stockA_beforeCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + const stockB_beforeCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-2').stock_quantity; // Cancel whole order 3 const cancelOrder3Res = await api(baseUrl, `/api/orders/${order3Id}/status`, { @@ -493,11 +734,23 @@ async function main() { }); assertEqual(cancelOrder3Res.status, 200, 'Order 3 cancel status code'); - const stockA_afterCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - const stockB_afterCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-2').stock_quantity; + const stockA_afterCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + const stockB_afterCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-2').stock_quantity; - assertEqual(stockA_afterCancel, stockA_beforeCancel, 'Item A (voided) stock MUST NOT be restored on whole-order cancel'); - assertEqual(stockB_afterCancel, stockB_beforeCancel + 1, 'Item B (active) stock MUST be restored on whole-order cancel'); + assertEqual( + stockA_afterCancel, + stockA_beforeCancel, + 'Item A (voided) stock MUST NOT be restored on whole-order cancel', + ); + assertEqual( + stockB_afterCancel, + stockB_beforeCancel + 1, + 'Item B (active) stock MUST be restored on whole-order cancel', + ); // Repeat whole order cancellation on Order 3 await api(baseUrl, `/api/orders/${order3Id}/status`, { @@ -506,10 +759,22 @@ async function main() { body: { status: 'cancelled', override_pin: '1234' }, }); - const stockA_afterRepeat = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - const stockB_afterRepeat = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-2').stock_quantity; - assertEqual(stockA_afterRepeat, stockA_afterCancel, 'Item A stock unchanged after repeated order cancel'); - assertEqual(stockB_afterRepeat, stockB_afterCancel, 'Item B stock unchanged after repeated order cancel'); + const stockA_afterRepeat = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + const stockB_afterRepeat = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-2').stock_quantity; + assertEqual( + stockA_afterRepeat, + stockA_afterCancel, + 'Item A stock unchanged after repeated order cancel', + ); + assertEqual( + stockB_afterRepeat, + stockB_afterCancel, + 'Item B stock unchanged after repeated order cancel', + ); // ═══════════════════════════════════════════════════════════════════ // 5. Pending Item in Preparing Order follows Pending Restock Contract @@ -529,7 +794,9 @@ async function main() { }, }); const order4PreId = order4Pre.data.order.id; - const itemPending = order4Pre.data.order.items.find((i: any) => i.product_id === 'prod-track-1'); + const itemPending = order4Pre.data.order.items.find( + (i: any) => i.product_id === 'prod-track-1', + ); // Move parent order to preparing, but item status remains pending await api(baseUrl, `/api/orders/${order4PreId}/status`, { @@ -538,26 +805,58 @@ async function main() { body: { status: 'preparing' }, }); - const itemPendingStatus = db.prepare('SELECT status FROM order_items WHERE id = ?').get(itemPending.id).status; - assertEqual(itemPendingStatus, 'pending', 'Item status is still pending while order is preparing'); + const itemPendingStatus = db + .prepare('SELECT status FROM order_items WHERE id = ?') + .get(itemPending.id).status; + assertEqual( + itemPendingStatus, + 'pending', + 'Item status is still pending while order is preparing', + ); - const stockBeforePendingCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + const stockBeforePendingCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; // Cancel pending item - const cancelPendingItemRes = await api(baseUrl, `/api/orders/${order4PreId}/items/${itemPending.id}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); - assertEqual(cancelPendingItemRes.status, 200, 'Cancel pending item in preparing order HTTP status'); + const cancelPendingItemRes = await api( + baseUrl, + `/api/orders/${order4PreId}/items/${itemPending.id}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); + assertEqual( + cancelPendingItemRes.status, + 200, + 'Cancel pending item in preparing order HTTP status', + ); - const itemStatusAfterCancel = db.prepare('SELECT status FROM order_items WHERE id = ?').get(itemPending.id).status; - assertEqual(itemStatusAfterCancel, 'cancelled', 'Pending item in preparing order becomes cancelled (not voided)'); + const itemStatusAfterCancel = db + .prepare('SELECT status FROM order_items WHERE id = ?') + .get(itemPending.id).status; + assertEqual( + itemStatusAfterCancel, + 'cancelled', + 'Pending item in preparing order becomes cancelled (not voided)', + ); - const stockAfterPendingCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - assertEqual(stockAfterPendingCancel, stockBeforePendingCancel + 1, 'Pending item in preparing order restores stock (+1)'); + const stockAfterPendingCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + assertEqual( + stockAfterPendingCancel, + stockBeforePendingCancel + 1, + 'Pending item in preparing order restores stock (+1)', + ); - const voidAdjustCount4 = db.prepare("SELECT COUNT(*) as count FROM order_items WHERE order_id = ? AND status = 'void_adjustment'").get(order4PreId).count; + const voidAdjustCount4 = db + .prepare( + "SELECT COUNT(*) as count FROM order_items WHERE order_id = ? AND status = 'void_adjustment'", + ) + .get(order4PreId).count; assertEqual(voidAdjustCount4, 0, 'No void_adjustment created for pending item'); // ═══════════════════════════════════════════════════════════════════ @@ -577,9 +876,13 @@ async function main() { }, }); const order4Id = order4.data.order.id; - const itemTrack1Id = order4.data.order.items.find((i: any) => i.product_id === 'prod-track-1').id; + const itemTrack1Id = order4.data.order.items.find( + (i: any) => i.product_id === 'prod-track-1', + ).id; - let stockTrack1 = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + let stockTrack1 = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; // Cancel pending item 1 const itemCancel1 = await api(baseUrl, `/api/orders/${order4Id}/items/${itemTrack1Id}/cancel`, { @@ -589,8 +892,14 @@ async function main() { }); assertEqual(itemCancel1.status, 200, 'Pending item cancel HTTP status'); - const stockTrack1_afterCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - assertEqual(stockTrack1_afterCancel, stockTrack1 + 1, 'Pending item cancel restores stock (+1)'); + const stockTrack1_afterCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + assertEqual( + stockTrack1_afterCancel, + stockTrack1 + 1, + 'Pending item cancel restores stock (+1)', + ); // Repeat cancel of already cancelled item const itemCancel2 = await api(baseUrl, `/api/orders/${order4Id}/items/${itemTrack1Id}/cancel`, { @@ -600,39 +909,77 @@ async function main() { }); assertEqual(itemCancel2.status, 200, 'Repeated pending item cancel HTTP status'); - const stockTrack1_afterRepeatCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - assertEqual(stockTrack1_afterRepeatCancel, stockTrack1_afterCancel, 'Repeated pending item cancel MUST NOT restore stock again'); + const stockTrack1_afterRepeatCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + assertEqual( + stockTrack1_afterRepeatCancel, + stockTrack1_afterCancel, + 'Repeated pending item cancel MUST NOT restore stock again', + ); - db.prepare("UPDATE users SET role = 'cashier', updated_at = ? WHERE id = 'owner-test-001'").run(new Date().toISOString()); - const staleRoleRestore = await api(baseUrl, `/api/orders/${order4Id}/items/${itemTrack1Id}/restore`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); - assertEqual(staleRoleRestore.status, 403, 'demoted actor cannot restore with a stale owner token'); - db.prepare("UPDATE users SET role = 'owner', updated_at = ? WHERE id = 'owner-test-001'").run(new Date().toISOString()); + db.prepare("UPDATE users SET role = 'cashier', updated_at = ? WHERE id = 'owner-test-001'").run( + new Date().toISOString(), + ); + const staleRoleRestore = await api( + baseUrl, + `/api/orders/${order4Id}/items/${itemTrack1Id}/restore`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); + assertEqual( + staleRoleRestore.status, + 403, + 'demoted actor cannot restore with a stale owner token', + ); + db.prepare("UPDATE users SET role = 'owner', updated_at = ? WHERE id = 'owner-test-001'").run( + new Date().toISOString(), + ); // Restore item 1 - const itemRestore1 = await api(baseUrl, `/api/orders/${order4Id}/items/${itemTrack1Id}/restore`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const itemRestore1 = await api( + baseUrl, + `/api/orders/${order4Id}/items/${itemTrack1Id}/restore`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(itemRestore1.status, 200, 'Item restore HTTP status'); - const stockTrack1_afterRestore = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - assertEqual(stockTrack1_afterRestore, stockTrack1_afterCancel - 1, 'Item restore re-deducts stock (-1)'); + const stockTrack1_afterRestore = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + assertEqual( + stockTrack1_afterRestore, + stockTrack1_afterCancel - 1, + 'Item restore re-deducts stock (-1)', + ); // Repeat restore of active item - const itemRestore2 = await api(baseUrl, `/api/orders/${order4Id}/items/${itemTrack1Id}/restore`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const itemRestore2 = await api( + baseUrl, + `/api/orders/${order4Id}/items/${itemTrack1Id}/restore`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(itemRestore2.status, 200, 'Repeated item restore HTTP status'); - const stockTrack1_afterRepeatRestore = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - assertEqual(stockTrack1_afterRepeatRestore, stockTrack1_afterRestore, 'Repeated item restore MUST NOT deduct stock again'); + const stockTrack1_afterRepeatRestore = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + assertEqual( + stockTrack1_afterRepeatRestore, + stockTrack1_afterRestore, + 'Repeated item restore MUST NOT deduct stock again', + ); const flagToggleOrder = await api(baseUrl, '/api/orders', { method: 'POST', @@ -648,23 +995,51 @@ async function main() { }, }); const flagToggleItemId = flagToggleOrder.data.order.items[0].id; - const flagToggleStock = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; - db.prepare("UPDATE products SET track_inventory = 0, updated_at = ? WHERE id = 'prod-track-1'").run(new Date().toISOString()); - const flagToggleCancel = await api(baseUrl, `/api/orders/${flagToggleOrder.data.order.id}/items/${flagToggleItemId}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); - assertEqual(flagToggleCancel.status, 200, 'item cancellation succeeds after tracking is disabled'); - assertEqual(db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity, flagToggleStock + 1, 'cancellation restores stock deducted before tracking was disabled'); - const flagToggleRestore = await api(baseUrl, `/api/orders/${flagToggleOrder.data.order.id}/items/${flagToggleItemId}/restore`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const flagToggleStock = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; + db.prepare( + "UPDATE products SET track_inventory = 0, updated_at = ? WHERE id = 'prod-track-1'", + ).run(new Date().toISOString()); + const flagToggleCancel = await api( + baseUrl, + `/api/orders/${flagToggleOrder.data.order.id}/items/${flagToggleItemId}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); + assertEqual( + flagToggleCancel.status, + 200, + 'item cancellation succeeds after tracking is disabled', + ); + assertEqual( + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1') + .stock_quantity, + flagToggleStock + 1, + 'cancellation restores stock deducted before tracking was disabled', + ); + const flagToggleRestore = await api( + baseUrl, + `/api/orders/${flagToggleOrder.data.order.id}/items/${flagToggleItemId}/restore`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(flagToggleRestore.status, 200, 'item restore succeeds after tracking is disabled'); - assertEqual(db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity, flagToggleStock, 'restore re-deducts the original inventory quantity'); - db.prepare("UPDATE products SET track_inventory = 1, updated_at = ? WHERE id = 'prod-track-1'").run(new Date().toISOString()); + assertEqual( + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1') + .stock_quantity, + flagToggleStock, + 'restore re-deducts the original inventory quantity', + ); + db.prepare( + "UPDATE products SET track_inventory = 1, updated_at = ? WHERE id = 'prod-track-1'", + ).run(new Date().toISOString()); const insufficientOrder = await api(baseUrl, '/api/orders', { method: 'POST', @@ -678,29 +1053,50 @@ async function main() { }, }); const insufficientOrderId = insufficientOrder.data.order.id; - const insufficientItemId = insufficientOrder.data.order.items.find((item: any) => item.product_id === 'prod-track-1').id; - const cancelForInsufficientRestore = await api(baseUrl, `/api/orders/${insufficientOrderId}/items/${insufficientItemId}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); - assertEqual(cancelForInsufficientRestore.status, 200, 'insufficient-stock restore fixture cancels the tracked item'); - const stockBeforeInsufficientRestore = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity; + const insufficientItemId = insufficientOrder.data.order.items.find( + (item: any) => item.product_id === 'prod-track-1', + ).id; + const cancelForInsufficientRestore = await api( + baseUrl, + `/api/orders/${insufficientOrderId}/items/${insufficientItemId}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); + assertEqual( + cancelForInsufficientRestore.status, + 200, + 'insufficient-stock restore fixture cancels the tracked item', + ); + const stockBeforeInsufficientRestore = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-1').stock_quantity; db.prepare('UPDATE products SET stock_quantity = 0 WHERE id = ?').run('prod-track-1'); - const insufficientRestore = await api(baseUrl, `/api/orders/${insufficientOrderId}/items/${insufficientItemId}/restore`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); - assertEqual(insufficientRestore.status, 400, 'restore rejects when tracked stock is insufficient'); + const insufficientRestore = await api( + baseUrl, + `/api/orders/${insufficientOrderId}/items/${insufficientItemId}/restore`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); + assertEqual( + insufficientRestore.status, + 400, + 'restore rejects when tracked stock is insufficient', + ); assertEqual( db.prepare('SELECT status FROM order_items WHERE id = ?').get(insufficientItemId).status, 'cancelled', 'insufficient-stock restore leaves the item cancelled', ); assertEqual( - db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1').stock_quantity, + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-1') + .stock_quantity, 0, 'insufficient-stock restore leaves stock unchanged', ); @@ -709,7 +1105,10 @@ async function main() { 'pending', 'insufficient-stock restore leaves the parent order active', ); - db.prepare('UPDATE products SET stock_quantity = ? WHERE id = ?').run(stockBeforeInsufficientRestore, 'prod-track-1'); + db.prepare('UPDATE products SET stock_quantity = ? WHERE id = ?').run( + stockBeforeInsufficientRestore, + 'prod-track-1', + ); // ═══════════════════════════════════════════════════════════════════ // 7. Final Item Cancellation / Auto-Cancel @@ -728,7 +1127,9 @@ async function main() { const order5Id = order5.data.order.id; const item5Id = order5.data.order.items[0].id; - let stockTrack2 = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-2').stock_quantity; + let stockTrack2 = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-2').stock_quantity; // Cancel final item (triggers order auto-cancel) await api(baseUrl, `/api/orders/${order5Id}/items/${item5Id}/cancel`, { @@ -740,27 +1141,46 @@ async function main() { const order5Status = db.prepare('SELECT status FROM orders WHERE id = ?').get(order5Id).status; assertEqual(order5Status, 'cancelled', 'Order auto-cancelled when final item cancelled'); - const stockTrack2_afterAutoCancel = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-2').stock_quantity; - assertEqual(stockTrack2_afterAutoCancel, stockTrack2 + 1, 'Stock track-2 restored exactly once on auto-cancel (+1)'); + const stockTrack2_afterAutoCancel = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-track-2').stock_quantity; + assertEqual( + stockTrack2_afterAutoCancel, + stockTrack2 + 1, + 'Stock track-2 restored exactly once on auto-cancel (+1)', + ); // ═══════════════════════════════════════════════════════════════════ // 8. Concurrent Whole-Order Cancellation // ═══════════════════════════════════════════════════════════════════ console.log('\n─── 8. Concurrent Whole-Order Cancellation ───'); - seedProduct(db, 'prod-concurrent', 'cat-252', 'Concurrent Burger', 120, { track_inventory: true, stock_quantity: 10 }); + seedProduct(db, 'prod-concurrent', 'cat-252', 'Concurrent Burger', 120, { + track_inventory: true, + stock_quantity: 10, + }); const orderConc = await api(baseUrl, '/api/orders', { method: 'POST', headers: authHeader, - body: { type: 'dine_in', table_id: 'tbl-252-2', items: [{ product_id: 'prod-concurrent', quantity: 2 }] }, + body: { + type: 'dine_in', + table_id: 'tbl-252-2', + items: [{ product_id: 'prod-concurrent', quantity: 2 }], + }, }); const concOrderId = orderConc.data.order.id; - let stockConc = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-concurrent').stock_quantity; + let stockConc = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-concurrent').stock_quantity; assertEqual(stockConc, 8, 'Stock deducted on creation (10 -> 8)'); - const removeConcurrentWriter = installConcurrentOrderBoundaryMutation(db, path.join(testDir, 'flo.db'), concOrderId); + const removeConcurrentWriter = installConcurrentOrderBoundaryMutation( + db, + path.join(testDir, 'flo.db'), + concOrderId, + ); let resConcA: any; let resConcB: any; try { @@ -768,12 +1188,20 @@ async function main() { api(baseUrl, `/api/orders/${concOrderId}/status`, { method: 'PATCH', headers: authHeader, - body: { status: 'cancelled', reason: 'Concurrent cancellation request A', override_pin: '1234' }, + body: { + status: 'cancelled', + reason: 'Concurrent cancellation request A', + override_pin: '1234', + }, }), api(baseUrl, `/api/orders/${concOrderId}/status`, { method: 'PATCH', headers: authHeader, - body: { status: 'cancelled', reason: 'Concurrent cancellation request B', override_pin: '1234' }, + body: { + status: 'cancelled', + reason: 'Concurrent cancellation request B', + override_pin: '1234', + }, }), ]); } finally { @@ -786,9 +1214,14 @@ async function main() { const finalOrderConc = db.prepare('SELECT status FROM orders WHERE id = ?').get(concOrderId); assertEqual(finalOrderConc.status, 'cancelled', 'Final order status is cancelled'); - stockConc = db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-concurrent').stock_quantity; - assertEqual(stockConc, 10, 'Inventory restored exactly once (8 -> 10, NOT 12) under concurrent requests'); - + stockConc = db + .prepare('SELECT stock_quantity FROM products WHERE id = ?') + .get('prod-concurrent').stock_quantity; + assertEqual( + stockConc, + 10, + 'Inventory restored exactly once (8 -> 10, NOT 12) under concurrent requests', + ); } finally { server.close(); closeDatabase(); diff --git a/tests/orders-authz.test.ts b/tests/orders-authz.test.ts index d36737f12..0a2ac2750 100644 --- a/tests/orders-authz.test.ts +++ b/tests/orders-authz.test.ts @@ -9,7 +9,8 @@ const os = require('os'); const path = require('path'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-orders-authz-')); 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); }; @@ -19,7 +20,14 @@ const bcrypt = require('bcryptjs'); const express = require('express'); const jwt = require('jsonwebtoken'); const { - initTestDb, startServer, api, assert, assertEqual, getResults, closeDatabase, now, + initTestDb, + startServer, + api, + assert, + assertEqual, + getResults, + closeDatabase, + now, } = require('./helpers/test-setup'); const { getJWTSecret } = require('../main/routes/auth'); const { orderRoutes, resetPinRateLimitForTests } = require('../main/routes/orders'); @@ -30,7 +38,16 @@ function seedUser(db: any, id: string, role: string, pin?: string) { db.prepare(` INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) - `).run(id, id, email, bcrypt.hashSync('testpass123', 10), role, pin ? bcrypt.hashSync(pin, 10) : null, now(), now()); + `).run( + id, + id, + email, + bcrypt.hashSync('testpass123', 10), + role, + pin ? bcrypt.hashSync(pin, 10) : null, + now(), + now(), + ); return { Authorization: `Bearer ${jwt.sign({ userId: id, email, role }, getJWTSecret(), { expiresIn: '1h' })}`, }; @@ -38,13 +55,23 @@ function seedUser(db: any, id: string, role: string, pin?: string) { function seedOrderWithItem(db: any, suffix: string, ownerId?: string) { db.prepare(`INSERT INTO orders (order_number, type, status, subtotal, total, user_id, created_at, updated_at) - VALUES (?, 'takeaway', 'pending', 100, 100, ?, ?, ?)`) - .run(`ORD-AUTHZ-${suffix}`, ownerId || null, now(), now()); - const orderId = (db.prepare('SELECT id FROM orders WHERE order_number = ?').get(`ORD-AUTHZ-${suffix}`) as any).id; + VALUES (?, 'takeaway', 'pending', 100, 100, ?, ?, ?)`).run( + `ORD-AUTHZ-${suffix}`, + ownerId || null, + now(), + now(), + ); + const orderId = ( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get(`ORD-AUTHZ-${suffix}`) as any + ).id; db.prepare(`INSERT INTO order_items (order_id, product_id, product_name, unit_price, quantity, subtotal, tax_amount, total, status, created_at, updated_at) - VALUES (?, 'authz-product', 'Authz item', 100, 1, 100, 0, 100, 'preparing', ?, ?)`) - .run(orderId, now(), now()); - const itemId = (db.prepare('SELECT id FROM order_items WHERE order_id = ?').get(orderId) as any).id; + VALUES (?, 'authz-product', 'Authz item', 100, 1, 100, 0, 100, 'preparing', ?, ?)`).run( + orderId, + now(), + now(), + ); + const itemId = (db.prepare('SELECT id FROM order_items WHERE order_id = ?').get(orderId) as any) + .id; return { orderId, itemId }; } @@ -54,19 +81,29 @@ async function main() { const ownerAuth = seedUser(db, 'owner-authz', 'owner', '5678'); const cashierAuth = seedUser(db, 'cashier-authz', 'cashier', '9876'); const waiterAuth = seedUser(db, 'server-authz', 'server'); - db.prepare(`INSERT INTO categories (id, name, sort_order) VALUES ('authz-category', 'Authz', 1)`).run(); + db.prepare( + `INSERT INTO categories (id, name, sort_order) VALUES ('authz-category', 'Authz', 1)`, + ).run(); db.prepare(`INSERT INTO products (id, category_id, name, price, is_active, sort_order) VALUES ('authz-product', 'authz-category', 'Authz item', 100, 1, 1)`).run(); db.prepare(`INSERT INTO printers (id, name, connection_type, ip_address, port, is_default, paper_width, created_at, updated_at) - VALUES ('authz-printer', 'Authz Printer', 'network', '127.0.0.1', 9100, 1, '80mm', ?, ?)`).run(now(), now()); + VALUES ('authz-printer', 'Authz Printer', 'network', '127.0.0.1', 9100, 1, '80mm', ?, ?)`).run( + now(), + now(), + ); const app = express(); app.use(express.json()); app.use((req: any, res: any, next: any) => { const header = req.headers.authorization; - if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'Authentication required' }); - try { req.user = jwt.verify(header.slice(7), getJWTSecret()); next(); } - catch { res.status(401).json({ error: 'Invalid token' }); } + if (!header?.startsWith('Bearer ')) + return res.status(401).json({ error: 'Authentication required' }); + try { + req.user = jwt.verify(header.slice(7), getJWTSecret()); + next(); + } catch { + res.status(401).json({ error: 'Invalid token' }); + } }); app.use('/api/orders', orderRoutes); registerRoutes(app); @@ -75,177 +112,408 @@ async function main() { try { const statusOrder = seedOrderWithItem(db, 'STATUS', 'cashier-authz'); const cashierStatus = await api(baseUrl, `/api/orders/${statusOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'preparing' }, headers: cashierAuth, + method: 'PATCH', + body: { status: 'preparing' }, + headers: cashierAuth, }); assertEqual(cashierStatus.status, 200, 'cashier can advance an order to preparing'); { const order = seedOrderWithItem(db, 'CASHIER-VOID', 'cashier-authz'); - const response = await api(baseUrl, `/api/orders/${order.orderId}/items/${order.itemId}/cancel`, { - method: 'PATCH', body: { override_pin: '1234' }, headers: cashierAuth, - }); - assertEqual(response.status, 200, 'cashier can void an in-progress item with a valid manager PIN'); - assertEqual((db.prepare('SELECT status FROM order_items WHERE id = ?').get(order.itemId) as any).status, 'voided', 'cashier void marks the original item voided'); + const response = await api( + baseUrl, + `/api/orders/${order.orderId}/items/${order.itemId}/cancel`, + { + method: 'PATCH', + body: { override_pin: '1234' }, + headers: cashierAuth, + }, + ); + assertEqual( + response.status, + 200, + 'cashier can void an in-progress item with a valid manager PIN', + ); + assertEqual( + (db.prepare('SELECT status FROM order_items WHERE id = ?').get(order.itemId) as any).status, + 'voided', + 'cashier void marks the original item voided', + ); } const ownerOrder = seedOrderWithItem(db, 'OWNER-PIN', 'owner-authz'); - const ownerVoid = await api(baseUrl, `/api/orders/${ownerOrder.orderId}/items/${ownerOrder.itemId}/cancel`, { - method: 'PATCH', body: { override_pin: '5678' }, headers: ownerAuth, - }); + const ownerVoid = await api( + baseUrl, + `/api/orders/${ownerOrder.orderId}/items/${ownerOrder.itemId}/cancel`, + { + method: 'PATCH', + body: { override_pin: '5678' }, + headers: ownerAuth, + }, + ); assertEqual(ownerVoid.status, 200, 'owner PIN can authorize an in-progress item void'); const cashierPinOrder = seedOrderWithItem(db, 'CASHIER-PIN', 'cashier-authz'); - const cashierPin = await api(baseUrl, `/api/orders/${cashierPinOrder.orderId}/items/${cashierPinOrder.itemId}/cancel`, { - method: 'PATCH', body: { override_pin: '9876' }, headers: cashierAuth, - }); + const cashierPin = await api( + baseUrl, + `/api/orders/${cashierPinOrder.orderId}/items/${cashierPinOrder.itemId}/cancel`, + { + method: 'PATCH', + body: { override_pin: '9876' }, + headers: cashierAuth, + }, + ); assertEqual(cashierPin.status, 403, 'cashier PIN cannot authorize an in-progress item void'); // Orders are never ownership-gated (docs/reference/product-invariants.md). const waiterOwnOrder = seedOrderWithItem(db, 'WAITER-OWN', 'server-authz'); const waiterCanAdvance = await api(baseUrl, `/api/orders/${waiterOwnOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'preparing' }, headers: waiterAuth, + method: 'PATCH', + body: { status: 'preparing' }, + headers: waiterAuth, }); assertEqual(waiterCanAdvance.status, 200, 'server can advance any order, including their own'); const otherOrder = seedOrderWithItem(db, 'WAITER-OTHER', 'cashier-authz'); const waiterOtherStatus = await api(baseUrl, `/api/orders/${otherOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'cancelled', override_pin: '1234' }, headers: waiterAuth, + method: 'PATCH', + body: { status: 'cancelled', override_pin: '1234' }, + headers: waiterAuth, }); - assertEqual(waiterOtherStatus.status, 200, 'server can cancel an order created by another user, given a valid manager PIN'); - const statusAuditRow = db.prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed' ORDER BY id DESC LIMIT 1`).get(otherOrder.orderId) as any; - assertEqual(statusAuditRow?.actor_user_id, 'server-authz', 'audit log records the server as the actor, not the order\'s original creator'); - assert(JSON.parse(statusAuditRow?.details_json || '{}').approved_by === 'manager-authz', 'audit log records which manager PIN approved the cancellation'); + assertEqual( + waiterOtherStatus.status, + 200, + 'server can cancel an order created by another user, given a valid manager PIN', + ); + const statusAuditRow = db + .prepare( + `SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed' ORDER BY id DESC LIMIT 1`, + ) + .get(otherOrder.orderId) as any; + assertEqual( + statusAuditRow?.actor_user_id, + 'server-authz', + "audit log records the server as the actor, not the order's original creator", + ); + assert( + JSON.parse(statusAuditRow?.details_json || '{}').approved_by === 'manager-authz', + 'audit log records which manager PIN approved the cancellation', + ); const otherItemOrder = seedOrderWithItem(db, 'WAITER-OTHER-ITEM', 'cashier-authz'); - const waiterOtherItem = await api(baseUrl, `/api/orders/${otherItemOrder.orderId}/items/${otherItemOrder.itemId}/cancel`, { - method: 'PATCH', body: { override_pin: '1234' }, headers: waiterAuth, - }); - assertEqual(waiterOtherItem.status, 200, 'server can void an item on an order created by another user'); - const itemAuditRow = db.prepare(`SELECT * FROM order_audit_log WHERE order_item_id = ? AND action = 'item_voided' ORDER BY id DESC LIMIT 1`).get(otherItemOrder.itemId) as any; - assertEqual(itemAuditRow?.actor_user_id, 'server-authz', 'audit log records the server as the actor for a cross-user item void'); + const waiterOtherItem = await api( + baseUrl, + `/api/orders/${otherItemOrder.orderId}/items/${otherItemOrder.itemId}/cancel`, + { + method: 'PATCH', + body: { override_pin: '1234' }, + headers: waiterAuth, + }, + ); + assertEqual( + waiterOtherItem.status, + 200, + 'server can void an item on an order created by another user', + ); + const itemAuditRow = db + .prepare( + `SELECT * FROM order_audit_log WHERE order_item_id = ? AND action = 'item_voided' ORDER BY id DESC LIMIT 1`, + ) + .get(otherItemOrder.itemId) as any; + assertEqual( + itemAuditRow?.actor_user_id, + 'server-authz', + 'audit log records the server as the actor for a cross-user item void', + ); const waiterKotOtherOrder = seedOrderWithItem(db, 'WAITER-KOT-OTHER', 'cashier-authz'); - db.prepare(`UPDATE order_items SET status = 'ready' WHERE order_id = ?`).run(waiterKotOtherOrder.orderId); + db.prepare(`UPDATE order_items SET status = 'ready' WHERE order_id = ?`).run( + waiterKotOtherOrder.orderId, + ); const waiterKotOther = await api(baseUrl, '/api/printers/print-kot', { - method: 'POST', body: { orderId: waiterKotOtherOrder.orderId }, headers: waiterAuth, + method: 'POST', + body: { orderId: waiterKotOtherOrder.orderId }, + headers: waiterAuth, }); - assertEqual(waiterKotOther.status, 200, 'server can print-kot for an order created by another user'); + assertEqual( + waiterKotOther.status, + 200, + 'server can print-kot for an order created by another user', + ); const invalidPinOrder = seedOrderWithItem(db, 'INVALID-PIN', 'cashier-authz'); - const invalidPin = await api(baseUrl, `/api/orders/${invalidPinOrder.orderId}/items/${invalidPinOrder.itemId}/cancel`, { - method: 'PATCH', body: { override_pin: '9999' }, headers: cashierAuth, - }); + const invalidPin = await api( + baseUrl, + `/api/orders/${invalidPinOrder.orderId}/items/${invalidPinOrder.itemId}/cancel`, + { + method: 'PATCH', + body: { override_pin: '9999' }, + headers: cashierAuth, + }, + ); assertEqual(invalidPin.status, 403, 'cashier with an invalid manager PIN is denied'); - assert(String(invalidPin.data.error).includes('Invalid manager PIN'), 'invalid PIN denial identifies the PIN, not the role'); + assert( + String(invalidPin.data.error).includes('Invalid manager PIN'), + 'invalid PIN denial identifies the PIN, not the role', + ); // #1056: Unpaid order completion safeguards & explicit owner/manager PIN override resetPinRateLimitForTests(); const unbilledOrder = seedOrderWithItem(db, 'UNBILLED-COMPLETE', 'owner-authz'); db.prepare(`UPDATE orders SET status = 'served' WHERE id = ?`).run(unbilledOrder.orderId); - db.prepare(`UPDATE order_items SET status = 'served' WHERE order_id = ?`).run(unbilledOrder.orderId); + db.prepare(`UPDATE order_items SET status = 'served' WHERE order_id = ?`).run( + unbilledOrder.orderId, + ); const ownerUnbilledNoPin = await api(baseUrl, `/api/orders/${unbilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: ownerAuth, + method: 'PATCH', + body: { status: 'completed' }, + headers: ownerAuth, }); - assertEqual(ownerUnbilledNoPin.status, 400, 'logged-in owner cannot complete an unbilled positive-total order without an explicit PIN'); - assertEqual((db.prepare('SELECT status FROM orders WHERE id = ?').get(unbilledOrder.orderId) as any).status, 'served', 'unbilled order remains served when PIN is omitted'); + assertEqual( + ownerUnbilledNoPin.status, + 400, + 'logged-in owner cannot complete an unbilled positive-total order without an explicit PIN', + ); + assertEqual( + (db.prepare('SELECT status FROM orders WHERE id = ?').get(unbilledOrder.orderId) as any) + .status, + 'served', + 'unbilled order remains served when PIN is omitted', + ); const unpaidBilledOrder = seedOrderWithItem(db, 'UNPAID-BILL-COMPLETE', 'manager-authz'); db.prepare(`UPDATE orders SET status = 'served' WHERE id = ?`).run(unpaidBilledOrder.orderId); - db.prepare(`UPDATE order_items SET status = 'served' WHERE order_id = ?`).run(unpaidBilledOrder.orderId); + db.prepare(`UPDATE order_items SET status = 'served' WHERE order_id = ?`).run( + unpaidBilledOrder.orderId, + ); const unpaidBillRes = await api(baseUrl, '/api/bills/generate', { - method: 'POST', body: { order_id: unpaidBilledOrder.orderId }, headers: managerAuth, + method: 'POST', + body: { order_id: unpaidBilledOrder.orderId }, + headers: managerAuth, }); assertEqual(unpaidBillRes.status, 201, 'generated bill for unpaid completion test'); const unpaidBillId = unpaidBillRes.data.bill.id; - const managerUnpaidNoPin = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: managerAuth, - }); - assertEqual(managerUnpaidNoPin.status, 400, 'logged-in manager cannot complete an order with an unpaid bill without an explicit PIN'); - - const cashierPinComplete = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '9876' }, headers: cashierAuth, - }); - assertEqual(cashierPinComplete.status, 403, 'cashier PIN cannot authorize unpaid order completion'); + const managerUnpaidNoPin = await api( + baseUrl, + `/api/orders/${unpaidBilledOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed' }, + headers: managerAuth, + }, + ); + assertEqual( + managerUnpaidNoPin.status, + 400, + 'logged-in manager cannot complete an order with an unpaid bill without an explicit PIN', + ); + + const cashierPinComplete = await api( + baseUrl, + `/api/orders/${unpaidBilledOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed', override_pin: '9876' }, + headers: cashierAuth, + }, + ); + assertEqual( + cashierPinComplete.status, + 403, + 'cashier PIN cannot authorize unpaid order completion', + ); // Inactive manager PIN must be rejected seedUser(db, 'inactive-mgr', 'manager', '4321'); db.prepare(`UPDATE users SET is_active = 0 WHERE id = 'inactive-mgr'`).run(); - const inactiveMgrComplete = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '4321' }, headers: cashierAuth, - }); - assertEqual(inactiveMgrComplete.status, 403, 'inactive manager PIN cannot authorize unpaid order completion'); + const inactiveMgrComplete = await api( + baseUrl, + `/api/orders/${unpaidBilledOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed', override_pin: '4321' }, + headers: cashierAuth, + }, + ); + assertEqual( + inactiveMgrComplete.status, + 403, + 'inactive manager PIN cannot authorize unpaid order completion', + ); // Manager lacking payments.take permission cannot authorize unpaid completion override - db.prepare(`INSERT INTO role_permission_overrides (role, permission_id, effect, updated_by, updated_at) VALUES ('manager', 'payments.take', 'deny', 'owner-authz', ?)`).run(now()); + db.prepare( + `INSERT INTO role_permission_overrides (role, permission_id, effect, updated_by, updated_at) VALUES ('manager', 'payments.take', 'deny', 'owner-authz', ?)`, + ).run(now()); try { - const noPayPermComplete = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '1234' }, headers: cashierAuth, - }); - assertEqual(noPayPermComplete.status, 403, 'manager without payments.take cannot approve unpaid completion'); + const noPayPermComplete = await api( + baseUrl, + `/api/orders/${unpaidBilledOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed', override_pin: '1234' }, + headers: cashierAuth, + }, + ); + assertEqual( + noPayPermComplete.status, + 403, + 'manager without payments.take cannot approve unpaid completion', + ); } finally { - db.prepare(`DELETE FROM role_permission_overrides WHERE role = 'manager' AND permission_id = 'payments.take'`).run(); + db.prepare( + `DELETE FROM role_permission_overrides WHERE role = 'manager' AND permission_id = 'payments.take'`, + ).run(); } // Rate limit after 5 failed PIN attempts on completion resetPinRateLimitForTests(); for (let i = 1; i <= 5; i++) { const bad = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '0000' }, headers: cashierAuth, + method: 'PATCH', + body: { status: 'completed', override_pin: '0000' }, + headers: cashierAuth, }); assertEqual(bad.status, 403, `failed completion PIN attempt ${i} returns 403`); } - const rateLimitedComplete = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '1234' }, headers: cashierAuth, - }); - assertEqual(rateLimitedComplete.status, 429, '6th completion PIN attempt after 5 failures is rate-limited with 429'); - assertEqual((db.prepare('SELECT status FROM orders WHERE id = ?').get(unpaidBilledOrder.orderId) as any).status, 'served', 'rate-limited attempt does not mutate order status'); + const rateLimitedComplete = await api( + baseUrl, + `/api/orders/${unpaidBilledOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed', override_pin: '1234' }, + headers: cashierAuth, + }, + ); + assertEqual( + rateLimitedComplete.status, + 429, + '6th completion PIN attempt after 5 failures is rate-limited with 429', + ); + assertEqual( + (db.prepare('SELECT status FROM orders WHERE id = ?').get(unpaidBilledOrder.orderId) as any) + .status, + 'served', + 'rate-limited attempt does not mutate order status', + ); resetPinRateLimitForTests(); - const validOverrideComplete = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '1234' }, headers: cashierAuth, - }); + const validOverrideComplete = await api( + baseUrl, + `/api/orders/${unpaidBilledOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed', override_pin: '1234' }, + headers: cashierAuth, + }, + ); assertEqual(validOverrideComplete.status, 200, 'valid manager PIN overrides unpaid completion'); - const billAfterOverride = db.prepare('SELECT payment_status, paid_amount, balance, payment_details FROM bills WHERE id = ?').get(unpaidBillId) as any; - assertEqual(billAfterOverride.payment_status, 'unpaid', 'completion override does not mark unpaid bill as paid'); - assertEqual(Number(billAfterOverride.paid_amount), 0, 'completion override does not manufacture paid_amount'); - assert(Number(billAfterOverride.balance) > 0, 'completion override preserves outstanding bill balance'); - - const completeAudits = db.prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed'`).all(unpaidBilledOrder.orderId) as any[]; - assertEqual(completeAudits.length, 1, 'valid completion override writes exactly one status_changed audit entry'); + const billAfterOverride = db + .prepare( + 'SELECT payment_status, paid_amount, balance, payment_details FROM bills WHERE id = ?', + ) + .get(unpaidBillId) as any; + assertEqual( + billAfterOverride.payment_status, + 'unpaid', + 'completion override does not mark unpaid bill as paid', + ); + assertEqual( + Number(billAfterOverride.paid_amount), + 0, + 'completion override does not manufacture paid_amount', + ); + assert( + Number(billAfterOverride.balance) > 0, + 'completion override preserves outstanding bill balance', + ); + + const completeAudits = db + .prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed'`) + .all(unpaidBilledOrder.orderId) as any[]; + assertEqual( + completeAudits.length, + 1, + 'valid completion override writes exactly one status_changed audit entry', + ); const completeAuditDetails = JSON.parse(completeAudits[0].details_json || '{}'); - assertEqual(completeAudits[0].actor_user_id, 'cashier-authz', 'completion audit records actor identity'); - assertEqual(completeAuditDetails.approved_by, 'manager-authz', 'completion audit records approver identity'); - assert(Array.isArray(completeAuditDetails.overridden_safeguards) && completeAuditDetails.overridden_safeguards.includes('unsettled_payment'), 'completion audit records unsettled_payment safeguard'); - assert(Array.isArray(completeAuditDetails.bill_ids) && completeAuditDetails.bill_ids.includes(unpaidBillId), 'completion audit records bill IDs'); - assert(Number(completeAuditDetails.outstanding_amount) > 0, 'completion audit records outstanding amount'); - assert(!String(completeAudits[0].details_json).includes('1234'), 'completion audit never logs the PIN'); + assertEqual( + completeAudits[0].actor_user_id, + 'cashier-authz', + 'completion audit records actor identity', + ); + assertEqual( + completeAuditDetails.approved_by, + 'manager-authz', + 'completion audit records approver identity', + ); + assert( + Array.isArray(completeAuditDetails.overridden_safeguards) && + completeAuditDetails.overridden_safeguards.includes('unsettled_payment'), + 'completion audit records unsettled_payment safeguard', + ); + assert( + Array.isArray(completeAuditDetails.bill_ids) && + completeAuditDetails.bill_ids.includes(unpaidBillId), + 'completion audit records bill IDs', + ); + assert( + Number(completeAuditDetails.outstanding_amount) > 0, + 'completion audit records outstanding amount', + ); + assert( + !String(completeAudits[0].details_json).includes('1234'), + 'completion audit never logs the PIN', + ); // Same-state retry on completed order is a harmless idempotent no-op without PIN const retryComplete = await api(baseUrl, `/api/orders/${unpaidBilledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: cashierAuth, + method: 'PATCH', + body: { status: 'completed' }, + headers: cashierAuth, }); assertEqual(retryComplete.status, 200, 'same-state completed retry succeeds without PIN'); - const completeAuditsAfterRetry = db.prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed'`).all(unpaidBilledOrder.orderId) as any[]; - assertEqual(completeAuditsAfterRetry.length, 1, 'same-state retry does not record a duplicate audit entry'); + const completeAuditsAfterRetry = db + .prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed'`) + .all(unpaidBilledOrder.orderId) as any[]; + assertEqual( + completeAuditsAfterRetry.length, + 1, + 'same-state retry does not record a duplicate audit entry', + ); // Ordinary settled completion succeeds without PIN when kitchen requirement is satisfied const settledOrder = seedOrderWithItem(db, 'SETTLED-COMPLETE', 'cashier-authz'); db.prepare(`UPDATE orders SET status = 'served' WHERE id = ?`).run(settledOrder.orderId); - db.prepare(`UPDATE order_items SET status = 'served' WHERE order_id = ?`).run(settledOrder.orderId); + db.prepare(`UPDATE order_items SET status = 'served' WHERE order_id = ?`).run( + settledOrder.orderId, + ); const settledBillGen = await api(baseUrl, '/api/bills/generate', { - method: 'POST', body: { order_id: settledOrder.orderId }, headers: cashierAuth, + method: 'POST', + body: { order_id: settledOrder.orderId }, + headers: cashierAuth, }); assertEqual(settledBillGen.status, 201, 'generated bill for settled completion test'); const settledPay = await api(baseUrl, `/api/bills/${settledBillGen.data.bill.id}/payment`, { - method: 'POST', body: { method: 'cash', amount: settledBillGen.data.bill.total }, headers: cashierAuth, + method: 'POST', + body: { method: 'cash', amount: settledBillGen.data.bill.total }, + headers: cashierAuth, }); assertEqual(settledPay.status, 200, 'paid bill for settled completion test'); - db.prepare(`UPDATE orders SET status = 'served', completed_at = NULL WHERE id = ?`).run(settledOrder.orderId); + db.prepare(`UPDATE orders SET status = 'served', completed_at = NULL WHERE id = ?`).run( + settledOrder.orderId, + ); const settledComplete = await api(baseUrl, `/api/orders/${settledOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: cashierAuth, + method: 'PATCH', + body: { status: 'completed' }, + headers: cashierAuth, }); - assertEqual(settledComplete.status, 200, 'settled order completes without requiring an override PIN'); + assertEqual( + settledComplete.status, + 200, + 'settled order completes without requiring an override PIN', + ); // Kitchen delivery enforcement on completion when require_kitchen_delivered_before_settlement is enabled db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES ('require_kitchen_delivered_before_settlement', 'true', ?) @@ -255,56 +523,125 @@ async function main() { try { const undeliveredPaidOrder = seedOrderWithItem(db, 'KITCHEN-BLOCK-COMPLETE', 'cashier-authz'); const undeliveredBillGen = await api(baseUrl, '/api/bills/generate', { - method: 'POST', body: { order_id: undeliveredPaidOrder.orderId }, headers: cashierAuth, + method: 'POST', + body: { order_id: undeliveredPaidOrder.orderId }, + headers: cashierAuth, }); assertEqual(undeliveredBillGen.status, 201, 'generated bill for undelivered completion test'); - const undeliveredPay = await api(baseUrl, `/api/bills/${undeliveredBillGen.data.bill.id}/payment`, { - method: 'POST', body: { method: 'cash', amount: undeliveredBillGen.data.bill.total, override_pin: '1234' }, headers: cashierAuth, - }); - assertEqual(undeliveredPay.status, 200, 'paid bill with kitchen override for undelivered completion test'); - db.prepare(`UPDATE orders SET status = 'preparing', completed_at = NULL WHERE id = ?`).run(undeliveredPaidOrder.orderId); - - const blockedKitchenComplete = await api(baseUrl, `/api/orders/${undeliveredPaidOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: ownerAuth, - }); - assertEqual(blockedKitchenComplete.status, 400, 'completion is blocked without PIN when kitchen delivery policy requires served items'); - - const overriddenKitchenComplete = await api(baseUrl, `/api/orders/${undeliveredPaidOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed', override_pin: '5678' }, headers: cashierAuth, - }); - assertEqual(overriddenKitchenComplete.status, 200, 'owner PIN overrides kitchen-undelivered completion block'); - const kitchenCompleteAudit = db.prepare(`SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed' ORDER BY id DESC LIMIT 1`).get(undeliveredPaidOrder.orderId) as any; + const undeliveredPay = await api( + baseUrl, + `/api/bills/${undeliveredBillGen.data.bill.id}/payment`, + { + method: 'POST', + body: { + method: 'cash', + amount: undeliveredBillGen.data.bill.total, + override_pin: '1234', + }, + headers: cashierAuth, + }, + ); + assertEqual( + undeliveredPay.status, + 200, + 'paid bill with kitchen override for undelivered completion test', + ); + db.prepare(`UPDATE orders SET status = 'preparing', completed_at = NULL WHERE id = ?`).run( + undeliveredPaidOrder.orderId, + ); + + const blockedKitchenComplete = await api( + baseUrl, + `/api/orders/${undeliveredPaidOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed' }, + headers: ownerAuth, + }, + ); + assertEqual( + blockedKitchenComplete.status, + 400, + 'completion is blocked without PIN when kitchen delivery policy requires served items', + ); + + const overriddenKitchenComplete = await api( + baseUrl, + `/api/orders/${undeliveredPaidOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed', override_pin: '5678' }, + headers: cashierAuth, + }, + ); + assertEqual( + overriddenKitchenComplete.status, + 200, + 'owner PIN overrides kitchen-undelivered completion block', + ); + const kitchenCompleteAudit = db + .prepare( + `SELECT * FROM order_audit_log WHERE order_id = ? AND action = 'status_changed' ORDER BY id DESC LIMIT 1`, + ) + .get(undeliveredPaidOrder.orderId) as any; const kitchenAuditDetails = JSON.parse(kitchenCompleteAudit?.details_json || '{}'); - assertEqual(kitchenAuditDetails.approved_by, 'owner-authz', 'kitchen completion override records owner approver'); - assert(Array.isArray(kitchenAuditDetails.overridden_safeguards) && kitchenAuditDetails.overridden_safeguards.includes('kitchen_undelivered'), 'kitchen completion override records kitchen_undelivered safeguard'); + assertEqual( + kitchenAuditDetails.approved_by, + 'owner-authz', + 'kitchen completion override records owner approver', + ); + assert( + Array.isArray(kitchenAuditDetails.overridden_safeguards) && + kitchenAuditDetails.overridden_safeguards.includes('kitchen_undelivered'), + 'kitchen completion override records kitchen_undelivered safeguard', + ); } finally { - db.prepare(`UPDATE settings SET value = 'false', updated_at = ? WHERE key = 'require_kitchen_delivered_before_settlement'`).run(now()); + db.prepare( + `UPDATE settings SET value = 'false', updated_at = ? WHERE key = 'require_kitchen_delivered_before_settlement'`, + ).run(now()); } // Split checks: completion requires all payable checks to be settled and cover order total const splitOrder = seedOrderWithItem(db, 'SPLIT-COMPLETE', 'cashier-authz'); - db.prepare(`UPDATE orders SET status = 'served', subtotal = 200, total = 200 WHERE id = ?`).run(splitOrder.orderId); - db.prepare(`UPDATE order_items SET status = 'served', quantity = 2, subtotal = 200, total = 200 WHERE id = ?`).run(splitOrder.itemId); + db.prepare(`UPDATE orders SET status = 'served', subtotal = 200, total = 200 WHERE id = ?`).run( + splitOrder.orderId, + ); + db.prepare( + `UPDATE order_items SET status = 'served', quantity = 2, subtotal = 200, total = 200 WHERE id = ?`, + ).run(splitOrder.itemId); db.prepare( `INSERT INTO bills (bill_number, order_id, split_group_id, subtotal, tax_amount, discount_amount, total, payment_status, paid_amount, balance, payment_details, created_at, updated_at) - VALUES ('BILL-SPLIT-A', ?, 'sg-1', 100, 0, 0, 100, 'paid', 100, 0, ?, ?, ?)` + VALUES ('BILL-SPLIT-A', ?, 'sg-1', 100, 0, 0, 100, 'paid', 100, 0, ?, ?, ?)`, ).run(splitOrder.orderId, JSON.stringify([{ method: 'cash', amount: 100 }]), now(), now()); db.prepare( `INSERT INTO bills (bill_number, order_id, split_group_id, subtotal, tax_amount, discount_amount, total, payment_status, paid_amount, balance, payment_details, created_at, updated_at) - VALUES ('BILL-SPLIT-B', ?, 'sg-1', 100, 0, 0, 100, 'unpaid', 0, 100, '[]', ?, ?)` + VALUES ('BILL-SPLIT-B', ?, 'sg-1', 100, 0, 0, 100, 'unpaid', 0, 100, '[]', ?, ?)`, ).run(splitOrder.orderId, now(), now()); const halfPaidSplitComplete = await api(baseUrl, `/api/orders/${splitOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: ownerAuth, + method: 'PATCH', + body: { status: 'completed' }, + headers: ownerAuth, }); - assertEqual(halfPaidSplitComplete.status, 400, 'split-check order with an unpaid check cannot complete without PIN'); + assertEqual( + halfPaidSplitComplete.status, + 400, + 'split-check order with an unpaid check cannot complete without PIN', + ); - db.prepare(`UPDATE bills SET payment_status = 'paid', paid_amount = 100, balance = 0, payment_details = ? WHERE bill_number = 'BILL-SPLIT-B'`) - .run(JSON.stringify([{ method: 'card', amount: 100 }])); + db.prepare( + `UPDATE bills SET payment_status = 'paid', paid_amount = 100, balance = 0, payment_details = ? WHERE bill_number = 'BILL-SPLIT-B'`, + ).run(JSON.stringify([{ method: 'card', amount: 100 }])); const fullPaidSplitComplete = await api(baseUrl, `/api/orders/${splitOrder.orderId}/status`, { - method: 'PATCH', body: { status: 'completed' }, headers: cashierAuth, + method: 'PATCH', + body: { status: 'completed' }, + headers: cashierAuth, }); - assertEqual(fullPaidSplitComplete.status, 200, 'split-check order completes without PIN once all payable checks are settled'); + assertEqual( + fullPaidSplitComplete.status, + 200, + 'split-check order completes without PIN once all payable checks are settled', + ); } finally { server.close(); closeDatabase(); @@ -315,4 +652,7 @@ async function main() { if (results.failed > 0) process.exit(1); } -main().catch((error: any) => { console.error(error); process.exit(1); }); +main().catch((error: any) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/recipe-order-lifecycle.test.ts b/tests/recipe-order-lifecycle.test.ts index 28ee61be7..db8a234fd 100644 --- a/tests/recipe-order-lifecycle.test.ts +++ b/tests/recipe-order-lifecycle.test.ts @@ -10,14 +10,26 @@ const os = require('os'); const path = require('path'); const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-recipe-lifecycle-')); 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, assertEqual, assert, getResults, resetCounters, closeDatabase, + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + seedTable, + api, + assertEqual, + assert, + getResults, + resetCounters, + closeDatabase, } = require('./helpers/test-setup'); const { registerRoutes } = require('../main/routes/index'); const { createSupply, getSupply, softDeleteSupply } = require('../main/services/supplies'); @@ -28,7 +40,8 @@ function stockOf(db: any, id: string): number { } function movementCount(db: any, supplyId: string, type: string): number { - return db.prepare('SELECT COUNT(*) AS c FROM supply_movements WHERE supply_id = ? AND movement_type = ?') + return db + .prepare('SELECT COUNT(*) AS c FROM supply_movements WHERE supply_id = ? AND movement_type = ?') .get(supplyId, type).c; } @@ -42,15 +55,29 @@ async function main() { seedManagerUser(db); seedCategory(db, 'cat-rec', 'Recipes'); - seedProduct(db, 'prod-latte', 'cat-rec', 'Latte', 250, { track_inventory: false, stock_quantity: 0 }); - seedProduct(db, 'prod-water', 'cat-rec', 'Water', 50, { track_inventory: false, stock_quantity: 0 }); + seedProduct(db, 'prod-latte', 'cat-rec', 'Latte', 250, { + track_inventory: false, + stock_quantity: 0, + }); + seedProduct(db, 'prod-water', 'cat-rec', 'Water', 50, { + track_inventory: false, + stock_quantity: 0, + }); seedTable(db, 'tbl-rec-1', 1, 4); const beans = createSupply(db, { - name: 'Beans', baseUnit: 'g', stockQuantity: 1000, lowStockThreshold: 100, actorUserId: userId, + name: 'Beans', + baseUnit: 'g', + stockQuantity: 1000, + lowStockThreshold: 100, + actorUserId: userId, }); const milkS = createSupply(db, { - name: 'Milk', baseUnit: 'ml', stockQuantity: 5000, lowStockThreshold: 500, actorUserId: userId, + name: 'Milk', + baseUnit: 'ml', + stockQuantity: 5000, + lowStockThreshold: 500, + actorUserId: userId, }); saveRecipe(db, { @@ -64,10 +91,26 @@ async function main() { const snap2 = buildRecipeSnapshot(db, 'prod-latte', 2); assert(snap2 !== null, 'snapshot built for active recipe'); - assertEqual(snap2!.components.find((c: any) => c.supply_id === beans.id)!.quantity, 36, 'snapshot scales linearly (2 x 18 g)'); - assertEqual(snap2!.components.find((c: any) => c.supply_id === milkS.id)!.quantity, 400, 'snapshot scales milk (2 x 200 ml)'); - assertEqual(buildRecipeSnapshot(db, 'prod-water', 1), null, 'product without recipe yields null snapshot'); - assertEqual(buildRecipeSnapshot(db, 'prod-latte', 0), null, 'non-positive order quantity yields null snapshot'); + assertEqual( + snap2!.components.find((c: any) => c.supply_id === beans.id)!.quantity, + 36, + 'snapshot scales linearly (2 x 18 g)', + ); + assertEqual( + snap2!.components.find((c: any) => c.supply_id === milkS.id)!.quantity, + 400, + 'snapshot scales milk (2 x 200 ml)', + ); + assertEqual( + buildRecipeSnapshot(db, 'prod-water', 1), + null, + 'product without recipe yields null snapshot', + ); + assertEqual( + buildRecipeSnapshot(db, 'prod-latte', 0), + null, + 'non-positive order quantity yields null snapshot', + ); const { orderRoutes } = require('../main/routes/orders'); const { orderItemRoutes } = require('../main/routes/order-items'); @@ -90,7 +133,11 @@ async function main() { ], }, }); - assertEqual(snakeCaseRecipe.status, 200, 'recipe API accepts documented snake_case supply_id payloads'); + assertEqual( + snakeCaseRecipe.status, + 200, + 'recipe API accepts documented snake_case supply_id payloads', + ); console.log('\n--- 1. Deplete at order creation ---'); const beansBefore = stockOf(db, beans.id); @@ -113,9 +160,14 @@ async function main() { assertEqual(stockOf(db, beans.id), beansBefore - 54, 'beans depleted by 3 x 18 g'); assertEqual(stockOf(db, milkS.id), milkBefore - 600, 'milk depleted by 3 x 200 ml'); - assertEqual(movementCount(db, beans.id, 'recipe_depletion'), 1, 'one recipe_depletion movement for beans'); + assertEqual( + movementCount(db, beans.id, 'recipe_depletion'), + 1, + 'one recipe_depletion movement for beans', + ); - const item1 = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND product_id = 'prod-latte' LIMIT 1") + const item1 = db + .prepare("SELECT * FROM order_items WHERE order_id = ? AND product_id = 'prod-latte' LIMIT 1") .get(order1.data.order.id); const parsedSnap = JSON.parse(item1.recipe_snapshot); assertEqual(parsedSnap.product_id, 'prod-latte', 'recipe_snapshot stores product id'); @@ -130,27 +182,47 @@ async function main() { const beansMid = stockOf(db, beans.id); const milkMid = stockOf(db, milkS.id); - const cancelRes = await api(baseUrl, `/api/orders/${order1.data.order.id}/items/${item1.id}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const cancelRes = await api( + baseUrl, + `/api/orders/${order1.data.order.id}/items/${item1.id}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(cancelRes.status, 200, 'pending item cancel succeeds'); - assertEqual(db.prepare('SELECT status FROM orders WHERE id = ?').get(order1.data.order.id).status, 'pending', 'order stays active with remaining item'); + assertEqual( + db.prepare('SELECT status FROM orders WHERE id = ?').get(order1.data.order.id).status, + 'pending', + 'order stays active with remaining item', + ); assertEqual(stockOf(db, beans.id), beansMid + 54, 'beans restored on pending cancel'); assertEqual(stockOf(db, milkS.id), milkMid + 600, 'milk restored on pending cancel'); - assertEqual(movementCount(db, beans.id, 'recipe_restore'), 1, 'one recipe_restore movement for beans'); + assertEqual( + movementCount(db, beans.id, 'recipe_restore'), + 1, + 'one recipe_restore movement for beans', + ); console.log('\n--- 3. Item restore re-depletes ---'); const beansPostCancel = stockOf(db, beans.id); - const restoreRes = await api(baseUrl, `/api/orders/${order1.data.order.id}/items/${item1.id}/restore`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const restoreRes = await api( + baseUrl, + `/api/orders/${order1.data.order.id}/items/${item1.id}/restore`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(restoreRes.status, 200, 'item restore succeeds'); assertEqual(stockOf(db, beans.id), beansPostCancel - 54, 'beans re-depleted on restore'); - assertEqual(movementCount(db, beans.id, 'recipe_depletion'), 2, 'second recipe_depletion recorded'); + assertEqual( + movementCount(db, beans.id, 'recipe_depletion'), + 2, + 'second recipe_depletion recorded', + ); console.log('\n--- 4. Whole-order cancel restores ---'); const beansBeforeCancel = stockOf(db, beans.id); @@ -184,7 +256,11 @@ async function main() { const order3 = await api(baseUrl, '/api/orders', { method: 'POST', headers: authHeader, - body: { type: 'dine_in', table_id: 'tbl-rec-1', items: [{ product_id: 'prod-latte', quantity: 1 }] }, + body: { + type: 'dine_in', + table_id: 'tbl-rec-1', + items: [{ product_id: 'prod-latte', quantity: 1 }], + }, }); const item3Id = order3.data.order.items[0].id; const beansBeforeVoid = stockOf(db, beans.id); @@ -194,11 +270,15 @@ async function main() { headers: authHeader, body: { status: 'preparing' }, }); - const voidRes = await api(baseUrl, `/api/orders/${order3.data.order.id}/items/${item3Id}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: { override_pin: '1234' }, - }); + const voidRes = await api( + baseUrl, + `/api/orders/${order3.data.order.id}/items/${item3Id}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: { override_pin: '1234' }, + }, + ); assertEqual(voidRes.status, 200, 'void of in-prep item succeeds'); assertEqual(stockOf(db, beans.id), beansBeforeVoid, 'void does NOT restore bean stock'); @@ -209,7 +289,11 @@ async function main() { body: { status: 'cancelled', override_pin: '1234' }, }); assertEqual(cancel3.status, 200, 'order with only voided item cancels'); - assertEqual(stockOf(db, beans.id), beansBeforeOrder3Cancel, 'whole-order cancel skips voided item supplies'); + assertEqual( + stockOf(db, beans.id), + beansBeforeOrder3Cancel, + 'whole-order cancel skips voided item supplies', + ); const mixedRecipeOrder = await api(baseUrl, '/api/orders', { method: 'POST', @@ -229,13 +313,21 @@ async function main() { headers: authHeader, body: { status: 'preparing' }, }); - const cancelMixedRecipe = await api(baseUrl, `/api/orders/${mixedRecipeOrder.data.order.id}/status`, { - method: 'PATCH', - headers: authHeader, - body: { status: 'cancelled', override_pin: '1234' }, - }); + const cancelMixedRecipe = await api( + baseUrl, + `/api/orders/${mixedRecipeOrder.data.order.id}/status`, + { + method: 'PATCH', + headers: authHeader, + body: { status: 'cancelled', override_pin: '1234' }, + }, + ); assertEqual(cancelMixedRecipe.status, 200, 'mixed recipe order cancels with manager PIN'); - assertEqual(stockOf(db, beans.id), beansAfterMixedCreate + 36, 'whole-order cancel restores recipe stock only for the pending item (2 x 18 g) and skips the preparing item (1 x 18 g)'); + assertEqual( + stockOf(db, beans.id), + beansAfterMixedCreate + 36, + 'whole-order cancel restores recipe stock only for the pending item (2 x 18 g) and skips the preparing item (1 x 18 g)', + ); console.log('\n--- 7. Inactive recipe skips depletion ---'); saveRecipe(db, { @@ -252,7 +344,9 @@ async function main() { }); assertEqual(order4.status, 201, 'order with inactive recipe created'); assertEqual(stockOf(db, beans.id), beansInactive, 'inactive recipe does not deplete'); - const item4 = db.prepare('SELECT recipe_snapshot FROM order_items WHERE order_id = ? LIMIT 1').get(order4.data.order.id); + const item4 = db + .prepare('SELECT recipe_snapshot FROM order_items WHERE order_id = ? LIMIT 1') + .get(order4.data.order.id); assertEqual(item4.recipe_snapshot, null, 'inactive recipe stores null snapshot'); console.log('\n--- 8. Yield scaling ---'); @@ -268,10 +362,17 @@ async function main() { body: { type: 'takeaway', items: [{ product_id: 'prod-latte', quantity: 2 }] }, }); assertEqual(order5.status, 201, 'order with scaled yield recipe created'); - assertEqual(stockOf(db, beans.id), beansYield - 36, 'yield scales depletion (72 g per 4 -> 36 g for 2)'); + assertEqual( + stockOf(db, beans.id), + beansYield - 36, + 'yield scales depletion (72 g per 4 -> 36 g for 2)', + ); console.log('\n--- 9. Product inventory independent of supplies ---'); - seedProduct(db, 'prod-track-both', 'cat-rec', 'Tracked', 100, { track_inventory: true, stock_quantity: 10 }); + seedProduct(db, 'prod-track-both', 'cat-rec', 'Tracked', 100, { + track_inventory: true, + stock_quantity: 10, + }); saveRecipe(db, { productId: 'prod-track-both', yieldQuantity: 1, @@ -284,9 +385,14 @@ async function main() { body: { type: 'takeaway', items: [{ product_id: 'prod-track-both', quantity: 1 }] }, }); assertEqual(order6.status, 201, 'tracked product with recipe created'); - assertEqual(stockOf(db, beans.id), beansBoth - 10, 'supplies depleted for tracked product with recipe'); assertEqual( - db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-both').stock_quantity, + stockOf(db, beans.id), + beansBoth - 10, + 'supplies depleted for tracked product with recipe', + ); + assertEqual( + db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-track-both') + .stock_quantity, 9, 'product stock also deducted (10 -> 9)', ); @@ -309,14 +415,19 @@ async function main() { yieldQuantity: 1, items: [{ supplyId: beans.id, quantity: 30, unit: 'g' }], }); - const item7 = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND product_id = 'prod-latte' LIMIT 1") + const item7 = db + .prepare("SELECT * FROM order_items WHERE order_id = ? AND product_id = 'prod-latte' LIMIT 1") .get(order7.data.order.id); const beansBeforeSnapshotCancel = stockOf(db, beans.id); - const cancel7 = await api(baseUrl, `/api/orders/${order7.data.order.id}/items/${item7.id}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const cancel7 = await api( + baseUrl, + `/api/orders/${order7.data.order.id}/items/${item7.id}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(cancel7.status, 200, 'item cancel after recipe edit succeeds'); assertEqual( stockOf(db, beans.id), @@ -326,9 +437,15 @@ async function main() { console.log('\n--- 11. Historical restore survives supply soft delete ---'); const historicalSupply = createSupply(db, { - name: 'Historical beans', baseUnit: 'g', stockQuantity: 100, actorUserId: userId, + name: 'Historical beans', + baseUnit: 'g', + stockQuantity: 100, + actorUserId: userId, + }); + seedProduct(db, 'prod-soft-delete', 'cat-rec', 'Soft-delete restore', 100, { + track_inventory: false, + stock_quantity: 0, }); - seedProduct(db, 'prod-soft-delete', 'cat-rec', 'Soft-delete restore', 100, { track_inventory: false, stock_quantity: 0 }); saveRecipe(db, { productId: 'prod-soft-delete', items: [{ supplyId: historicalSupply.id, quantity: 18, unit: 'g' }], @@ -342,21 +459,29 @@ async function main() { const historicalItem = historicalOrder.data.order.items[0]; deleteRecipe(db, 'prod-soft-delete'); softDeleteSupply(db, historicalSupply.id); - const historicalCancel = await api(baseUrl, `/api/orders/${historicalOrder.data.order.id}/items/${historicalItem.id}/cancel`, { - method: 'PATCH', - headers: authHeader, - body: {}, - }); + const historicalCancel = await api( + baseUrl, + `/api/orders/${historicalOrder.data.order.id}/items/${historicalItem.id}/cancel`, + { + method: 'PATCH', + headers: authHeader, + body: {}, + }, + ); assertEqual(historicalCancel.status, 200, 'cancel restores from a soft-deleted supply'); assertEqual( - db.prepare('SELECT stock_quantity FROM supplies WHERE id = ?').get(historicalSupply.id).stock_quantity, + db.prepare('SELECT stock_quantity FROM supplies WHERE id = ?').get(historicalSupply.id) + .stock_quantity, 100, 'soft-deleted supply stock is restored from historical snapshot', ); console.log('\n--- 12. Variant portions scale the same recipe ---'); const syrup = createSupply(db, { - name: 'Syrup', baseUnit: 'ml', stockQuantity: 1000, actorUserId: userId, + name: 'Syrup', + baseUnit: 'ml', + stockQuantity: 1000, + actorUserId: userId, }); saveRecipe(db, { productId: 'prod-latte', @@ -370,11 +495,31 @@ async function main() { const component = (snapshot: any, supplyId: string) => snapshot.components.find((entry: any) => entry.supply_id === supplyId)!.quantity; const halfPortion = buildRecipeSnapshot(db, 'prod-latte', 2, 0.5)!; - assertEqual(component(halfPortion, beans.id), 18, 'a half portion of two servings depletes half the beans'); - assertEqual(component(halfPortion, syrup.id), 400, 'a half portion converts litres to base ml before scaling'); - assertEqual(component(buildRecipeSnapshot(db, 'prod-latte', 2)!, beans.id), 36, 'the default portion is one'); - assertEqual(component(buildRecipeSnapshot(db, 'prod-latte', 2, 2)!, beans.id), 72, 'a double portion doubles the base recipe'); - assertEqual(buildRecipeSnapshot(db, 'prod-water', 1, 2), null, 'a portion has no effect without an active recipe'); + assertEqual( + component(halfPortion, beans.id), + 18, + 'a half portion of two servings depletes half the beans', + ); + assertEqual( + component(halfPortion, syrup.id), + 400, + 'a half portion converts litres to base ml before scaling', + ); + assertEqual( + component(buildRecipeSnapshot(db, 'prod-latte', 2)!, beans.id), + 36, + 'the default portion is one', + ); + assertEqual( + component(buildRecipeSnapshot(db, 'prod-latte', 2, 2)!, beans.id), + 72, + 'a double portion doubles the base recipe', + ); + assertEqual( + buildRecipeSnapshot(db, 'prod-water', 1, 2), + null, + 'a portion has no effect without an active recipe', + ); assertEqual( buildRecipeSnapshot(db, 'prod-water', 1, 0), null, From b7ddb1ee7b3d985b1623dfbb7920d839165ab3db Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 21:31:04 -0400 Subject: [PATCH 3/5] no-mistakes(ci): Diagnosed and fixed the CI failure in `Core Test Suite (Shard 2/4)` (`npm run test:authorization-permissions`): - **Invariant violated**: Static source assertions in [`tests/authorization-static-audit.test.ts`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M4YG5QA5XNS987YQ0FE5FHP3/tests/authorization-static-audit.test.ts) checking that `OrderCard` gates item cancellation on `canCancelItems && !isPaid` must tolerate Oxfmt multi-line wrapping across boolean operands. - **Root cause**: When [`frontend/src/components/orders/OrderCard.tsx`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M4YG5QA5XNS987YQ0FE5FHP3/frontend/src/components/orders/OrderCard.tsx) was formatted by Oxfmt in commit `135938d6`, `{canCancelItems && !isPaid && !['completed', 'cancelled'].includes(order.status) && (` was wrapped across multiple lines (`{canCancelItems &&\n !isPaid &&`), causing `assert.match(orderCard, /\{canCancelItems && !isPaid/)` in [`tests/authorization-static-audit.test.ts`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M4YG5QA5XNS987YQ0FE5FHP3/tests/authorization-static-audit.test.ts) to fail. - **Fix**: Updated the regex in [`tests/authorization-static-audit.test.ts`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M4YG5QA5XNS987YQ0FE5FHP3/tests/authorization-static-audit.test.ts) to `/\{canCancelItems &&\s+!isPaid/` and formatted the modified test file with `oxfmt`. - **Verification**: - `npm run test:authorization-permissions` (passed) - `node scripts/ci/check-changed-format.cjs --base 309db4aa8d20996c0eeff52810830ae6d681eb26` (passed) - `npm run test:delivery-slip && npm run test:rtl-dashboard-pos-common && npm run test:variant-pos-surface` (passed) - `npm run lint` and `npm run docs:check` (passed) --- tests/authorization-static-audit.test.ts | 68 +++++++++++++++++++----- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/tests/authorization-static-audit.test.ts b/tests/authorization-static-audit.test.ts index 9d63dbc8c..05d6818fa 100644 --- a/tests/authorization-static-audit.test.ts +++ b/tests/authorization-static-audit.test.ts @@ -9,7 +9,11 @@ const root = path.resolve(__dirname, '..'); function filesUnder(directory: string): string[] { return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const fullPath = path.join(directory, entry.name); - return entry.isDirectory() ? filesUnder(fullPath) : entry.name.endsWith('.ts') ? [fullPath] : []; + return entry.isDirectory() + ? filesUnder(fullPath) + : entry.name.endsWith('.ts') + ? [fullPath] + : []; }); } @@ -46,21 +50,35 @@ function run(): void { for (const file of runtimeFiles) { const source = fs.readFileSync(file, 'utf8'); - assert.doesNotMatch(source, /\brequireRole\s*\(/, `${toPosixPath(path.relative(root, file))} must authorize with permissions`); + assert.doesNotMatch( + source, + /\brequireRole\s*\(/, + `${toPosixPath(path.relative(root, file))} must authorize with permissions`, + ); } for (const file of runtimeFiles) { const relative = path.relative(root, file); if (isReviewedRolePolicyFile(relative)) continue; const source = fs.readFileSync(file, 'utf8'); - assert.doesNotMatch(source, /\bhasRole\s*\(/, `${toPosixPath(relative)} contains an unreviewed direct role check`); + assert.doesNotMatch( + source, + /\bhasRole\s*\(/, + `${toPosixPath(relative)} contains an unreviewed direct role check`, + ); } // The order card used to take one `isOwnerOrManager` prop and gate the refund // button, the voided-items list, and item restoration with it, which let one // permission quietly govern three others in both directions. - const ordersPage = fs.readFileSync(path.join(root, 'frontend/src/app/(dashboard)/orders/page.tsx'), 'utf8'); - const orderCard = fs.readFileSync(path.join(root, 'frontend/src/components/orders/OrderCard.tsx'), 'utf8'); + const ordersPage = fs.readFileSync( + path.join(root, 'frontend/src/app/(dashboard)/orders/page.tsx'), + 'utf8', + ); + const orderCard = fs.readFileSync( + path.join(root, 'frontend/src/components/orders/OrderCard.tsx'), + 'utf8', + ); for (const permissionId of ['orders.item.cancel', 'orders.item.restore', 'refunds.initiate']) { assert.ok( @@ -69,14 +87,40 @@ function run(): void { ); } for (const capability of ['canCancelItems', 'canRestoreItems', 'canRefund']) { - assert.ok(orderCard.includes(`${capability}: boolean;`), `OrderCard takes ${capability} as its own capability`); - assert.ok(ordersPage.includes(`${capability}={${capability}}`), `the orders page passes ${capability} through unchanged`); + assert.ok( + orderCard.includes(`${capability}: boolean;`), + `OrderCard takes ${capability} as its own capability`, + ); + assert.ok( + ordersPage.includes(`${capability}={${capability}}`), + `the orders page passes ${capability} through unchanged`, + ); } - assert.doesNotMatch(orderCard, /isOwnerOrManager/, 'the order card no longer collapses three permissions into one flag'); - assert.match(orderCard, /\{canCancelItems && !isPaid/, 'item cancellation is gated on orders.item.cancel'); - assert.match(orderCard, /\{inactiveItems\.length > 0 && canRestoreItems && \(/, 'viewing voided items is gated on orders.item.restore'); - assert.match(orderCard, /\{showVoidedItems && inactiveItems\.length > 0 && canRestoreItems && \(/, 'restoring voided items is gated on orders.item.restore'); - assert.match(orderCard, /\{canRefund && hasEligibleRefund && \(/, 'the refund button is gated on refunds.initiate'); + assert.doesNotMatch( + orderCard, + /isOwnerOrManager/, + 'the order card no longer collapses three permissions into one flag', + ); + assert.match( + orderCard, + /\{canCancelItems &&\s+!isPaid/, + 'item cancellation is gated on orders.item.cancel', + ); + assert.match( + orderCard, + /\{inactiveItems\.length > 0 && canRestoreItems && \(/, + 'viewing voided items is gated on orders.item.restore', + ); + assert.match( + orderCard, + /\{showVoidedItems && inactiveItems\.length > 0 && canRestoreItems && \(/, + 'restoring voided items is gated on orders.item.restore', + ); + assert.match( + orderCard, + /\{canRefund && hasEligibleRefund && \(/, + 'the refund button is gated on refunds.initiate', + ); console.log('Authorization static enforcement audit passed'); } From 4e89ba769670145525ede7cafb6eb594c4bf69a7 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 21:51:30 -0400 Subject: [PATCH 4/5] fix(orders): surface cancel PIN prompt on late payment and propagate currency errors --- .../e2e/ops-admin/orders-lifecycle.spec.ts | 53 +++++++++++++++++++ frontend/src/app/(dashboard)/orders/page.tsx | 8 ++- main/routes/orders.ts | 13 ++--- tests/orders-authz.test.ts | 31 +++++++++++ 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/frontend/e2e/ops-admin/orders-lifecycle.spec.ts b/frontend/e2e/ops-admin/orders-lifecycle.spec.ts index a7e84769e..b77ea010c 100644 --- a/frontend/e2e/ops-admin/orders-lifecycle.spec.ts +++ b/frontend/e2e/ops-admin/orders-lifecycle.spec.ts @@ -298,6 +298,59 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { }; expect(Number(storedBill.paid_amount)).toBe(20); expect(storedBill.payment_status).toBe('partial'); + + // If another till records a partial payment after the cancel dialog is already open, + // the initial Confirm Cancel rejection keeps the dialog open and reveals the PIN input. + const latePayCreated = await page.request.post(`${E2E_BASE_URL}/api/orders`, { + headers, + data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 2 }] }, + }); + expect(latePayCreated.status()).toBe(201); + const { order: latePayOrder } = (await latePayCreated.json()) as { + order: { id: number; order_number: string }; + }; + const lateBillGen = await page.request.post(`${E2E_BASE_URL}/api/bills/generate`, { + headers, + data: { order_id: latePayOrder.id }, + }); + expect(lateBillGen.status()).toBe(201); + const { bill: lateBill } = (await lateBillGen.json()) as { bill: { id: number } }; + + await page.goto(`${E2E_BASE_URL}/orders`); + const lateRow = page + .getByRole('button') + .filter({ hasText: `#${latePayOrder.order_number}` }) + .first(); + await expect(lateRow).toBeVisible(); + await lateRow.click(); + + await openOrderOverflowMenu(page); + await page.getByRole('menuitem', { name: /^Cancel$/ }).click(); + + const lateSheet = overlay(page); + await expect(lateSheet).toBeVisible(); + await expect(lateSheet.locator('#overridePin')).toHaveCount(0); + + // Another till collects partial payment while the dialog is open. + const concurrentPay = await page.request.post( + `${E2E_BASE_URL}/api/bills/${lateBill.id}/payment`, + { + headers, + data: { method: 'cash', amount: 15 }, + }, + ); + expect(concurrentPay.ok()).toBeTruthy(); + + await lateSheet.getByRole('button', { name: /Confirm Cancel/i }).click(); + await expect(lateSheet).toBeVisible(); + await expect(lateSheet.locator('#overridePin')).toBeVisible(); + + await lateSheet.locator('#overridePin').fill(OWNER_APPROVAL_PIN); + await lateSheet.getByRole('button', { name: /Confirm Cancel/i }).click(); + await expect( + page.getByRole('button').filter({ hasText: `#${latePayOrder.order_number}` }), + ).toHaveCount(0); + expect((await fetchOrder(page, latePayOrder.order_number)).status).toBe('cancelled'); }); test("the Orders screen shows this order's own totals, and each line its own gross", async ({ diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index 03725d293..13f2952f5 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -1379,7 +1379,13 @@ export default function OrdersPage() { setCancelModal(null); fetchOrders(undefined, { refreshLoadedPages: true }); } catch (error) { - const message = error instanceof Error ? error.message : ''; + const err = error as { response?: { data?: { error?: string } }; message?: string }; + const message = + typeof err?.response?.data?.error === 'string' + ? err.response.data.error + : typeof err?.message === 'string' + ? err.message + : ''; if (/PIN/i.test(message)) { setCancelModal((prev) => (prev ? { ...prev, requiresPin: true } : prev)); } diff --git a/main/routes/orders.ts b/main/routes/orders.ts index c6c7b3d42..6b0304e92 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -2109,16 +2109,9 @@ router.patch( const statusOrder = ['pending', 'preparing', 'ready', 'served', 'completed']; const currentStatusIndex = statusOrder.indexOf(currentOrder.status); - let minorFactor = 100; - let fractionDigits = 2; - try { - const tenantCurrency = getTenantCurrency(); - minorFactor = getCurrencyMinorUnitFactor(tenantCurrency); - fractionDigits = getCurrencyFractionDigits(tenantCurrency); - } catch { - minorFactor = 100; - fractionDigits = 2; - } + const tenantCurrency = getTenantCurrency(); + const minorFactor = getCurrencyMinorUnitFactor(tenantCurrency); + const fractionDigits = getCurrencyFractionDigits(tenantCurrency); const roundCurrency = (amount: number): number => fractionDigits === 0 ? Math.round(amount) : Number(amount.toFixed(fractionDigits)); diff --git a/tests/orders-authz.test.ts b/tests/orders-authz.test.ts index 0a2ac2750..974908b51 100644 --- a/tests/orders-authz.test.ts +++ b/tests/orders-authz.test.ts @@ -642,6 +642,37 @@ async function main() { 200, 'split-check order completes without PIN once all payable checks are settled', ); + + // Missing regional currency/country settings must fail with 409 RegionalNotConfiguredError + const unconfiguredCurrencyOrder = seedOrderWithItem(db, 'NO-CURRENCY', 'cashier-authz'); + const savedSettings = db + .prepare(`SELECT key, value FROM settings WHERE key IN ('currency', 'country')`) + .all() as { key: string; value: string }[]; + try { + db.prepare(`DELETE FROM settings WHERE key IN ('currency', 'country')`).run(); + const missingRegionalRes = await api( + baseUrl, + `/api/orders/${unconfiguredCurrencyOrder.orderId}/status`, + { + method: 'PATCH', + body: { status: 'completed' }, + headers: ownerAuth, + }, + ); + assertEqual( + missingRegionalRes.status, + 409, + 'missing regional settings return 409 instead of guessing currency decimals', + ); + } finally { + for (const row of savedSettings) { + db.prepare(`INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)`).run( + row.key, + row.value, + now(), + ); + } + } } finally { server.close(); closeDatabase(); From c386f2f520a1d8fe3013f0552b3d6a9b9d5f4751 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 14 Oct 2026 22:20:31 -0400 Subject: [PATCH 5/5] no-mistakes(document): docs: deduplicate order status safeguard references and update invariants --- docs/architecture/order-lifecycle.md | 6 ++---- docs/reference/product-invariants.md | 13 ++++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index 7f4e733f6..e7a01a705 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -166,10 +166,8 @@ enabled, excluding the share funded by wallet points. ## Order status `PATCH /api/orders/:id/status` advances the order through preparation, service, and terminal -statuses inside a single transaction. Terminal transitions (`completed` and `cancelled`) evaluate -bill settlement, collected funds, item progress, and kitchen delivery inside the transaction without -mutating bill or payment records, and `cancelled` restores stock only for `pending` items; allowed -transitions, `override_pin` approval rules, and audit fields are in the +statuses inside a single transaction. Its allowed transitions, terminal completion and cancellation +safeguards, `override_pin` approval rules, pending-only restock rule, and audit fields are in the [API reference](../reference/api.md#orders). Kitchen item status is a separate surface (`/api/order-items`); see [order-item status](../reference/api.md#order-item-status). diff --git a/docs/reference/product-invariants.md b/docs/reference/product-invariants.md index 11fb7b039..543fb7834 100644 --- a/docs/reference/product-invariants.md +++ b/docs/reference/product-invariants.md @@ -98,9 +98,8 @@ npm run test:authorization-permissions ``` A static audit test in the same suite rejects any newly introduced `requireRole(...)` runtime gate -under `main/routes/`; the remaining direct role checks in the codebase are reviewed context policy -(refund approver identity, the override-PIN holder for item cancel/void, the last-active-owner and -staff-target rule, and KDS station/category scope), each documented where it lives. +under `main/routes/`; the remaining direct role checks in the codebase are reviewed context policy, +documented in [authentication and authorization](../architecture/authentication-and-authorization.md#configurable-permissions). **Change policy:** the two protected permissions, and the precedence order overrides resolve in, are structural — changing either needs an explicit product decision, because either one can create @@ -304,9 +303,9 @@ tables (`supplies`, `supply_movements`) with a signed ledger, separate from prod sales without a variant use a portion of 1, and the portion is scaled from the resolved catalog variant, never from the order payload. 3. **Restoration is keyed off the snapshot, never the current recipe or portion.** Cancelling a - pending item or an order restores exactly what the snapshot recorded, even if the recipe or the - variant's portion was edited since. Cancelling a half-portion sale therefore returns half of - every ingredient even after the variant was switched to double. + pending item (directly or by cancelling the order) restores exactly what the snapshot recorded, + even if the recipe or the variant's portion was edited since. Cancelling a half-portion sale + therefore returns half of every ingredient even after the variant was switched to double. 4. **Void after preparation is physical waste.** Items voided while in progress, ready, or completed restore neither supplies nor product stock. Only a `pending` cancel restores. 5. **Refunds never restore supplies**, matching product inventory behaviour. @@ -328,7 +327,7 @@ later. Snapshots keep historical cancellations correct under recipe edits, mirro **Enforced by:** `applySupplyStockChange()` in `main/services/supplies.ts`, which does not clamp; `buildRecipeSnapshot()` and `applyRecipeSnapshot()` in `main/services/recipes.ts`; the order -creation, item append, item cancel and item restore paths in `main/routes/orders.ts`. Restore acts +creation, item append, order and item cancel, and item restore paths in `main/routes/orders.ts`. Restore acts only on a `cancelled` item, re-deducts its inventory and recipe components, and returns it to `pending`; a `voided` item is never restored. The portion comes from `resolveOrderItemVariant()` in `main/services/product-variants.ts`, the same server-side resolution