diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index c4fb59848..077a21835 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -35,7 +35,8 @@ list. `requireRole` (in `main/middleware/security.ts`) still exists, but a stati (`tests/authorization-static-audit.test.ts`) fails the build if a new call site under `main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered in their own sections below (KDS station/category scope, refund approver identity, the -override-PIN holder for item cancel/void, and the last-active-owner/staff-target rule). +override-PIN holder for order-status and item cancel/void overrides, and the +last-active-owner/staff-target rule). **Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in order: the protected-owner rule (for `authorization.manage` and `staff.privileged.manage` only, @@ -126,15 +127,14 @@ a router, in [`main/routes/index.ts`](../../main/routes/index.ts). A static sear **Gates inside transaction bodies.** The most important case. Some endpoints perform the permission check inside the `withTxn` callback, after the transaction opens, using -`hasPermission(actorId, id)` against state read in the same transaction. `PATCH -/api/orders/:orderId/items/:itemId/cancel` and the matching `.../restore` endpoint in -`main/routes/orders.ts` do this, and -the override-PIN path additionally requires the presenting user to satisfy `hasRole(role, -ROLE_ACCESS.ownerManager)` — a deliberate context-policy role check, not a permission, because the -override PIN belongs to a person acting in an owner/manager capacity regardless of what their -`orders.item.void`/`orders.item.restore` grant says. The check cannot be hoisted into middleware -without changing the read the decision is based on, and it is invisible to anything that inspects -the route's middleware chain. +`hasPermission(actorId, id)` against state read in the same transaction. +`PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, and the matching +`.../restore` endpoint in `main/routes/orders.ts` do this, and their override-PIN paths +additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a +deliberate context-policy role check, not a permission, because the override PIN belongs to a +person acting in an owner/manager capacity regardless of what their individual permission grants +say. The check cannot be hoisted into middleware without changing the read the decision is based +on, and it is invisible to anything that inspects the route's middleware chain. **KDS WebSocket authorization.** The KDS socket authenticates with the same bearer token but authorizes per message. See below. diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index 5ff819aec..e7a01a705 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -166,7 +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. Its allowed transitions, no-op rule, and cancellation approval requirements 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/api.md b/docs/reference/api.md index dcc5bced9..535b2604c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -282,7 +282,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` | - | @@ -296,6 +296,32 @@ 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. +- When `approver_id` (or `manager_id`) is supplied on an overridden transition, PIN verification is + restricted to that active user. Every overridden `status_changed` audit log entry records + `actor_user_id`, `approved_by`, `overridden_safeguards`, `bill_ids`, `outstanding_amount`, + `outstanding_amounts`, and `collected_amount` (plus `undelivered_items` when overriding kitchen + delivery), 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/product-invariants.md b/docs/reference/product-invariants.md index 0b4692975..49dd4d047 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 diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index 57dd987af..5864e5c0a 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. Completing an order blocked by +unsettled payable bills, an unbilled positive total, or undelivered kitchen items (when +`require_kitchen_delivered_before_settlement` applies) likewise 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. Full safeguard, restock, and audit rules are in +[the Orders API reference](api.md#orders). **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..1d800e26d 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,24 @@ 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 @@ -40,13 +58,11 @@ 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 } }; + 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); } @@ -55,8 +71,12 @@ test.describe('@ci-tier2 operations admin - order history', () => { headers: ownerAuth(), data: { type: 'delivery', items: [{ product_id: 'e2e-product', quantity: 1 }] }, }); - const { order: delivery } = (await withCustomer.json()) as { order: { id: number; order_number: string; total: number; subtotal: number } }; - const customers = (await (await request.get(`${E2E_BASE_URL}/api/customers`, { headers: ownerAuth() })).json()) as { data: Array<{ name: string; id: string }> }; + const { order: delivery } = (await withCustomer.json()) as { + order: { id: number; order_number: string; total: number; subtotal: number }; + }; + const customers = (await ( + await request.get(`${E2E_BASE_URL}/api/customers`, { headers: ownerAuth() }) + ).json()) as { data: Array<{ name: string; id: string }> }; const attached = await request.patch(`${E2E_BASE_URL}/api/orders/${delivery.id}/customer`, { headers: ownerAuth(), data: { customer_id: customers.data.find((c) => c.name === customerName)!.id }, @@ -65,11 +85,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`, { @@ -128,53 +144,82 @@ test.describe('@ci-tier2 operations admin - order history', () => { await page.goto(`${E2E_BASE_URL}/orders`); // "All" is the default and includes the order still awaiting payment. - await expect(page.getByRole('button').filter({ hasText: `#${open.order_number}` }).first()).toBeVisible(); + await expect( + page + .getByRole('button') + .filter({ hasText: `#${open.order_number}` }) + .first(), + ).toBeVisible(); // The Active pill and the status dropdown both apply, so the pill has to be // widened before the dropdown can mean "settled sales". await page.getByRole('button', { name: 'All', exact: true }).first().click(); - await page.locator('select').filter({ has: page.locator('option[value="completed"]') }).selectOption('completed'); + await page + .locator('select') + .filter({ has: page.locator('option[value="completed"]') }) + .selectOption('completed'); await page.waitForTimeout(1200); - await expect(page.getByRole('button').filter({ hasText: `#${open.order_number}` })).toHaveCount(0); + await expect(page.getByRole('button').filter({ hasText: `#${open.order_number}` })).toHaveCount( + 0, + ); for (const sale of settled) { - await expect(page.getByRole('button').filter({ hasText: `#${sale.order_number}` }).first()).toBeVisible(); + await expect( + page + .getByRole('button') + .filter({ hasText: `#${sale.order_number}` }) + .first(), + ).toBeVisible(); } }); - test('FINDING: the Active pill and the Completed dropdown contradict each other', async ({ page, request }) => { + test('FINDING: the Active pill and the Completed dropdown contradict each other', async ({ + page, + request, + }) => { // The pill and the status dropdown are applied together, and "Active" counts // a completed order as inactive once its items are served. So leaving the // pill alone while asking for Completed hides precisely the settled sales // the operator is trying to find, with nothing on screen to explain it. await login(page, 'owner'); - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); const created = await request.post(`${E2E_BASE_URL}/api/orders`, { headers, 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`); // Asked for Completed while the Active pill is still selected: not listed. - await page.locator('select').filter({ has: page.locator('option[value="completed"]') }).selectOption('completed'); + await page + .locator('select') + .filter({ has: page.locator('option[value="completed"]') }) + .selectOption('completed'); await page.waitForTimeout(1200); - await expect(page.getByRole('button').filter({ hasText: `#${order.order_number}` })).toHaveCount(0); + await expect( + page.getByRole('button').filter({ hasText: `#${order.order_number}` }), + ).toHaveCount(0); // The very same order appears the moment the pill is widened. await page.getByRole('button', { name: 'All', exact: true }).first().click(); await page.waitForTimeout(1200); - await expect(page.getByRole('button').filter({ hasText: `#${order.order_number}` }).first()).toBeVisible(); + await expect( + page + .getByRole('button') + .filter({ hasText: `#${order.order_number}` }) + .first(), + ).toBeVisible(); }); - test('FINDING: search only sees the orders already loaded, and older sales are unreachable', async ({ page, request }) => { + test('FINDING: search only sees the orders already loaded, and older sales are unreachable', async ({ + page, + request, + }) => { // Filling past the page size is rate limited, so this scenario legitimately // takes longer than the default budget. test.setTimeout(240_000); @@ -185,14 +230,20 @@ test.describe('@ci-tier2 operations admin - order history', () => { // newest 50 cannot be found, and the screen says "No orders found" for an // order that genuinely exists. await login(page, 'owner'); - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); // Push a known order out of the loaded window by creating more than a // page's worth after it, so this holds whatever else the database holds. - const target = ((await (await request.post(`${E2E_BASE_URL}/api/orders`, { - headers, - data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 1 }] }, - })).json()) as { order: { order_number: string } }).order; + const target = ( + (await ( + await request.post(`${E2E_BASE_URL}/api/orders`, { + headers, + data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 1 }] }, + }) + ).json()) as { order: { order_number: string } } + ).order; // The order write route is rate limited, so a burst this size has to wait // its turn rather than fail the scenario on a 429. @@ -209,38 +260,57 @@ test.describe('@ci-tier2 operations admin - order history', () => { data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 1 }] }, }); } - expect(res.ok(), `filler order ${i + 1}/${FILLER} must be created (got ${res.status()})`).toBeTruthy(); + expect( + res.ok(), + `filler order ${i + 1}/${FILLER} must be created (got ${res.status()})`, + ).toBeTruthy(); } // The backend holds it, and its own listing can still reach it. const listed = await request.get(`${E2E_BASE_URL}/api/orders?per_page=200`, { headers }); const { orders } = (await listed.json()) as { orders: Array<{ order_number: string }> }; - expect(orders.some((o) => o.order_number === target.order_number), 'the order must exist in the store').toBeTruthy(); + expect( + orders.some((o) => o.order_number === target.order_number), + 'the order must exist in the store', + ).toBeTruthy(); // The screen, however, cannot find it. await page.goto(`${E2E_BASE_URL}/orders`); await page.getByRole('button', { name: 'All', exact: true }).first().click(); - await page.getByPlaceholder('Search by order number, name, or phone…').fill(target.order_number); + await page + .getByPlaceholder('Search by order number, name, or phone…') + .fill(target.order_number); await page.waitForTimeout(2000); - await expect(page.getByRole('button').filter({ hasText: `#${target.order_number}` })).toHaveCount(0); + await expect( + page.getByRole('button').filter({ hasText: `#${target.order_number}` }), + ).toHaveCount(0); await expect(page.getByText(/No orders found/i)).toBeVisible(); // And there is no control that would let the operator widen the window. - await expect(page.getByRole('button').filter({ hasText: /Load more|Next page|Show more/i })).toHaveCount(0); + await expect( + page.getByRole('button').filter({ hasText: /Load more|Next page|Show more/i }), + ).toHaveCount(0); }); test('the type filter separates takeaway from delivery', async ({ page, request }) => { await login(page, 'owner'); - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); // Provisioned here rather than reused from beforeAll, because the search // scenario above deliberately fills the list past its loaded page size and // would otherwise push these out of the window this screen fetches. - const made = async (type: string) => ((await (await request.post(`${E2E_BASE_URL}/api/orders`, { - headers, - data: { type, items: [{ product_id: 'e2e-product', quantity: 1 }] }, - })).json()) as { order: { order_number: string } }).order; + const made = async (type: string) => + ( + (await ( + await request.post(`${E2E_BASE_URL}/api/orders`, { + headers, + data: { type, items: [{ product_id: 'e2e-product', quantity: 1 }] }, + }) + ).json()) as { order: { order_number: string } } + ).order; const delivery = await made('delivery'); const takeaway = await made('takeaway'); @@ -248,12 +318,22 @@ test.describe('@ci-tier2 operations admin - order history', () => { await page.goto(`${E2E_BASE_URL}/orders`); await page.getByRole('button', { name: 'All', exact: true }).first().click(); - await page.locator('select').filter({ has: page.locator('option[value="delivery"]') }).selectOption('delivery'); + await page + .locator('select') + .filter({ has: page.locator('option[value="delivery"]') }) + .selectOption('delivery'); await page.waitForTimeout(1200); // The delivery order is listed and the takeaway one is not. - await expect(page.getByRole('button').filter({ hasText: `#${delivery.order_number}` }).first()).toBeVisible(); - await expect(page.getByRole('button').filter({ hasText: `#${takeaway.order_number}` })).toHaveCount(0); + await expect( + page + .getByRole('button') + .filter({ hasText: `#${delivery.order_number}` }) + .first(), + ).toBeVisible(); + await expect( + page.getByRole('button').filter({ hasText: `#${takeaway.order_number}` }), + ).toHaveCount(0); // And everything still listed really is a delivery. const rows = page.getByRole('button').filter({ hasText: /ORD-/ }); @@ -264,7 +344,9 @@ test.describe('@ci-tier2 operations admin - order history', () => { test('a historical order opens showing exactly what was sold', async ({ page, request }) => { await login(page, 'owner'); - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); // Provisioned here for the same reason as the type-filter scenario: the // search scenario fills the list past the window this screen fetches. @@ -273,27 +355,37 @@ test.describe('@ci-tier2 operations admin - order history', () => { data: { type: 'takeaway', items: [{ product_id: 'e2e-product', quantity: 2 }] }, }); 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(); + const sale = ( + (await created.json()) as { + order: { id: number; order_number: string; subtotal: number; total: number }; + } + ).order; + await settleOrderViaBill(request, headers, sale.id); await page.goto(`${E2E_BASE_URL}/orders`); await page.getByRole('button', { name: 'All', exact: true }).first().click(); - await page.locator('select').filter({ has: page.locator('option[value="completed"]') }).selectOption('completed'); + await page + .locator('select') + .filter({ has: page.locator('option[value="completed"]') }) + .selectOption('completed'); await page.waitForTimeout(1000); // The two-quantity sale: 2 x 60.00 net, 7% tax on top. expect(sale.subtotal).toBe(120); - await page.getByRole('button').filter({ hasText: `#${sale.order_number}` }).first().click(); + await page + .getByRole('button') + .filter({ hasText: `#${sale.order_number}` }) + .first() + .click(); await expect(page.getByText(`#${sale.order_number}`).nth(1)).toBeVisible(); const detail = await page.locator('body').innerText(); expect(money(detail.match(/Subtotal\s*฿([\d,]+\.\d{2})/)?.[1] ?? '')).toBe(sale.subtotal); - expect(money(detail.match(/Tax\s*฿([\d,]+\.\d{2})/)?.[1] ?? '')).toBeCloseTo(sale.total - sale.subtotal, 2); + expect(money(detail.match(/Tax\s*฿([\d,]+\.\d{2})/)?.[1] ?? '')).toBeCloseTo( + sale.total - sale.subtotal, + 2, + ); expect(money(detail.match(/Total\s*฿([\d,]+\.\d{2})/)?.[1] ?? '')).toBeCloseTo(sale.total, 2); // The line itself, and the gross of it. @@ -313,11 +405,17 @@ test.describe('@ci-tier2 operations admin - order history', () => { const orderNumber = await placeOrderAndPayCash(page); await page.goto(`${E2E_BASE_URL}/orders`); - await page.locator('select').filter({ has: page.locator('option[value="completed"]') }).selectOption('completed'); + await page + .locator('select') + .filter({ has: page.locator('option[value="completed"]') }) + .selectOption('completed'); await page.waitForTimeout(1200); - const row = page.getByRole('button').filter({ hasText: `#${orderNumber}` }).first(); + const row = page + .getByRole('button') + .filter({ hasText: `#${orderNumber}` }) + .first(); await expect(row).toBeVisible(); await expect(row).toContainText('Completed'); }); -}); \ No newline at end of file +}); diff --git a/frontend/e2e/ops-admin/orders-lifecycle.spec.ts b/frontend/e2e/ops-admin/orders-lifecycle.spec.ts index dd807db22..b77ea010c 100644 --- a/frontend/e2e/ops-admin/orders-lifecycle.spec.ts +++ b/frontend/e2e/ops-admin/orders-lifecycle.spec.ts @@ -1,9 +1,19 @@ import { test, expect } from '@playwright/test'; import { E2E_BASE_URL } from '../helpers/urls'; import { - login, addToCart, placeOrderAndPayCash, fetchOrder, fetchProductStock, - createTrackedProduct, deleteProduct, openOrderOverflowMenu, setApprovalPin, - gross, money, fmt, overlay, + login, + addToCart, + placeOrderAndPayCash, + fetchOrder, + fetchProductStock, + createTrackedProduct, + deleteProduct, + openOrderOverflowMenu, + setApprovalPin, + gross, + money, + fmt, + overlay, } from './helpers'; /** @@ -42,7 +52,9 @@ test.afterAll(async ({ request }) => { }); test.describe('@ci-tier2 operations admin - order lifecycle', () => { - test('a POS order, settled in cash, matches the record it created line for line', async ({ page }) => { + test('a POS order, settled in cash, matches the record it created line for line', async ({ + page, + }) => { await login(page, 'owner'); await page.goto(`${E2E_BASE_URL}/pos`); await expect(page.getByTestId('pos-product-grid')).toBeVisible(); @@ -65,14 +77,19 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { expect(order.items.reduce((sum, i) => sum + i.quantity, 0)).toBe(3); }); - test('a paid order offers a refund, and issuing it settles against the recorded sale', async ({ page }) => { + test('a paid order offers a refund, and issuing it settles against the recorded sale', async ({ + page, + }) => { await login(page, 'owner'); await page.goto(`${E2E_BASE_URL}/pos`); await addToCart(page, 'E2E Coffee', 1); const orderNumber = await placeOrderAndPayCash(page); await page.goto(`${E2E_BASE_URL}/orders`); - const row = page.getByRole('button').filter({ hasText: `#${orderNumber}` }).first(); + const row = page + .getByRole('button') + .filter({ hasText: `#${orderNumber}` }) + .first(); await expect(row).toBeVisible(); await row.click(); @@ -97,19 +114,32 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { await expect(page.getByText(/Refund issued/i).first()).toBeVisible({ timeout: 30_000 }); // The refund is on the record, and the original sale is still a completed order. - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); const listed = await page.request.get(`${E2E_BASE_URL}/api/refunds`, { headers }); expect(listed.ok()).toBeTruthy(); - const { refunds } = (await listed.json()) as { refunds: Array<{ amount_cents: number; method: string; approved_by: string }> }; - const issued = refunds.find((r) => r.approved_by === 'e2e-owner' && r.amount_cents === Math.round(paid.total * 100)); - expect(issued, 'the issued refund must be recorded for the amount that was collected').toBeTruthy(); + const { refunds } = (await listed.json()) as { + refunds: Array<{ amount_cents: number; method: string; approved_by: string }>; + }; + const issued = refunds.find( + (r) => r.approved_by === 'e2e-owner' && r.amount_cents === Math.round(paid.total * 100), + ); + expect( + issued, + 'the issued refund must be recorded for the amount that was collected', + ).toBeTruthy(); expect(issued?.method).toBe('cash'); expect((await fetchOrder(page, orderNumber)).status).toBe('completed'); }); - test('adding a line to an open order recomputes tax and deducts the extra stock', async ({ page }) => { + test('adding a line to an open order recomputes tax and deducts the extra stock', async ({ + page, + }) => { await login(page, 'owner'); - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); const before = await fetchProductStock(page, tracked.id); @@ -118,7 +148,9 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { data: { type: 'takeaway', items: [{ product_id: tracked.id, quantity: 2 }] }, }); expect(created.status()).toBe(201); - const { order } = (await created.json()) as { order: { id: number; order_number: string; subtotal: number } }; + const { order } = (await created.json()) as { + order: { id: number; order_number: string; subtotal: number }; + }; expect(order.subtotal).toBe(80); expect(await fetchProductStock(page, tracked.id)).toBe(before - 2); @@ -127,7 +159,10 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { headers, data: { items: [{ product_id: tracked.id, quantity: 1 }] }, }); - expect(added.ok(), `adding a line to an open order must succeed (got ${added.status()})`).toBeTruthy(); + expect( + added.ok(), + `adding a line to an open order must succeed (got ${added.status()})`, + ).toBeTruthy(); const reloaded = await fetchOrder(page, order.order_number); expect(reloaded.subtotal).toBe(120); @@ -138,7 +173,9 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { test('cancelling an unpaid order returns every deducted unit to stock', async ({ page }) => { await login(page, 'owner'); - const headers = await (async () => ({ Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}` }))(); + const headers = await (async () => ({ + Authorization: `Bearer ${await page.evaluate(() => localStorage.getItem('token'))}`, + }))(); const before = await fetchProductStock(page, cancelProbe.id); @@ -147,14 +184,19 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { data: { type: 'takeaway', items: [{ product_id: cancelProbe.id, quantity: 3 }] }, }); expect(created.status()).toBe(201); - const { order } = (await created.json()) as { order: { id: number; order_number: string; subtotal: number } }; + const { order } = (await created.json()) as { + order: { id: number; order_number: string; subtotal: number }; + }; expect(order.subtotal).toBe(90); expect(await fetchProductStock(page, cancelProbe.id)).toBe(before - 3); // Cancel from the Orders screen, the way staff would. await page.goto(`${E2E_BASE_URL}/orders`); - const row = page.getByRole('button').filter({ hasText: `#${order.order_number}` }).first(); + const row = page + .getByRole('button') + .filter({ hasText: `#${order.order_number}` }) + .first(); await expect(row).toBeVisible(); await row.click(); @@ -172,8 +214,12 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { // The operator gets a confirmation, and the cancelled order leaves the // Active list it was on. - await expect(page.getByText(/Order cancelled successfully/i).first()).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole('button').filter({ hasText: `#${order.order_number}` })).toHaveCount(0); + await expect(page.getByText(/Order cancelled successfully/i).first()).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByRole('button').filter({ hasText: `#${order.order_number}` }), + ).toHaveCount(0); const after = await fetchOrder(page, order.order_number); expect(after.status).toBe('cancelled'); @@ -181,23 +227,160 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { expect(await fetchProductStock(page, cancelProbe.id)).toBe(before); }); - test('the Orders screen shows this order\'s own totals, and each line its own gross', async ({ page }) => { + 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 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'); + + // 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 ({ + 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: 'dine_in', guest_count: 2, - items: [{ product_id: 'e2e-product', quantity: 1 }, { product_id: tracked.id, quantity: 2 }], + items: [ + { product_id: 'e2e-product', quantity: 1 }, + { product_id: tracked.id, quantity: 2 }, + ], }, }); expect(created.status()).toBe(201); const { order } = (await created.json()) as { order: { order_number: string } }; await page.goto(`${E2E_BASE_URL}/orders`); - await page.getByRole('button').filter({ hasText: `#${order.order_number}` }).first().click(); + await page + .getByRole('button') + .filter({ hasText: `#${order.order_number}` }) + .first() + .click(); const detail = await page.locator('body').innerText(); const shown = { @@ -217,4 +400,4 @@ test.describe('@ci-tier2 operations admin - order lifecycle', () => { await expect(page.getByText(`฿${fmt(gross(60))}`).last()).toBeVisible(); await expect(page.getByText(`฿${fmt(gross(80))}`).last()).toBeVisible(); }); -}); \ No newline at end of file +}); 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..13f2952f5 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -85,6 +85,36 @@ 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 +1376,22 @@ export default function OrdersPage() { override_pin: cancelModal.overridePin || undefined, }); toast.success(tOrders('orderCancelled')); + setCancelModal(null); fetchOrders(undefined, { refreshLoadedPages: true }); - } catch { + } catch (error) { + 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)); + } toast.error(tOrders('cancelOrderFailed')); } finally { setCancellingOrderId(null); - setCancelModal(null); } }; @@ -1854,10 +1894,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(' · ')}
)} @@ -639,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 })} + )} @@ -654,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 && ( + + )}
))}
@@ -774,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 edeb6fd5f..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,30 +170,62 @@ 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; + return ( + (paidAmount > 0 && status !== 'refunded') || + status === 'partial' || + status === 'partially_refunded' || + (status === 'paid' && (total > 0 || detailsCount > 0)) + ); + }); + const cancelRequiresPin = + order.status !== 'pending' || + 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] || [] : []; @@ -177,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 ; } })(); @@ -200,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')) && ( )} @@ -240,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) && ( @@ -265,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" > - {order.status === 'pending' - ? - : } + {!cancelRequiresPin ? ( + + ) : ( + + )} {cancellingOrderId === order.id ? tOrders('cancelling') : tCommon('cancel')} @@ -293,7 +375,9 @@ function OrderDetailContent({
{receiptStatusBadge && ( - + {receiptStatusBadge.labelKey === 'sent' && } {receiptStatusBadge.labelKey === 'failed' && } {receiptStatusLabel} @@ -313,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), + })}
)} @@ -330,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 /> -
@@ -346,9 +436,17 @@ function OrderDetailContent({ >
{customer.name} - {customer.phone && {customer.phone}} + {customer.phone && ( + + {customer.phone} + + )}
- {linkingCustomer && {tOrders('linking')}} + {linkingCustomer && ( + + {tOrders('linking')} + + )} ))}
@@ -360,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')}
) : ( @@ -373,7 +473,9 @@ function OrderDetailContent({ {order.customer.name} {order.customer.phone && ( - {order.customer.phone} + + {order.customer.phone} + )} {order.type === 'delivery' && order.customer.address && ( @@ -383,7 +485,9 @@ function OrderDetailContent({ )} ) : ( - {tOrders('walkInCustomer')} + + {tOrders('walkInCustomer')} + )} {order.customer && ( @@ -423,7 +527,10 @@ function OrderDetailContent({ {tOrders('orderNoteTap', { count: 1 })} - + {showOrderNotes && (
@@ -442,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}" +
)}
); @@ -495,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 && ( + + )}
))}
@@ -603,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), })} @@ -625,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 && ( - )} @@ -661,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')} )} @@ -674,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')} )} @@ -723,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 768835db1..55e4254ec 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -266,6 +266,15 @@ export function checkPinRateLimit(key: string): boolean { return true; } +function isPinRateLimited(key: string): boolean { + const entry = pinAttempts.get(key); + if (!entry || Date.now() > entry.resetAt) { + pinAttempts.delete(key); + return false; + } + return entry.count >= PIN_MAX_ATTEMPTS; +} + export function resetPinRateLimitForTests(): void { pinAttempts.clear(); } @@ -2109,8 +2118,8 @@ router.patch( ); } - // Cancellation authorization is based on the same transaction-local - // order and item snapshot that will be mutated below. + // Evaluate item progress, kitchen delivery policy, and bill/payment state + // inside the authoritative transaction. const hasItemsInProgress = db .prepare(` @@ -2121,40 +2130,224 @@ router.patch( .get(req.params.id) !== undefined; const statusOrder = ['pending', 'preparing', 'ready', 'served', 'completed']; const currentStatusIndex = statusOrder.indexOf(currentOrder.status); - const requiresOverride = - (currentStatusIndex > 0 || hasItemsInProgress) && status === 'cancelled'; + + 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)); + + const billRows = db + .prepare(` + SELECT b.*, + COALESCE((SELECT SUM(r.amount_cents) FROM refunds r WHERE r.bill_id = b.id), 0) AS refunded_cents + FROM bills b + WHERE b.order_id = ? + ORDER BY b.id ASC + `) + .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)); + const roundOffMinor = Math.round(Number(b.round_off || 0) * minorFactor); + const unroundedTotalMinor = Math.max(0, totalMinor - roundOffMinor); + const paidMinor = Math.max(0, Math.round(Number(b.paid_amount || 0) * minorFactor)); + const refundedMinor = Math.max(0, Math.round(Number(b.refunded_cents || 0))); + const fallbackBalance = Math.max(0, Number(b.total || 0) - Number(b.paid_amount || 0)); + const storedBalanceMinor = Math.max( + 0, + Math.round(Number(b.balance ?? fallbackBalance) * minorFactor), + ); + let parsedPaymentDetails: Array<{ amount?: unknown }> = []; + if (typeof b.payment_details === 'string' && b.payment_details.trim()) { + try { + const parsed = JSON.parse(b.payment_details); + if (Array.isArray(parsed)) parsedPaymentDetails = parsed; + } catch { + parsedPaymentDetails = []; + } + } else if (Array.isArray(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)), + 0, + ); + const recordedPaidMinor = Math.max(paidMinor, detailsPaidMinor); + const netCollectedMinor = Math.max(0, recordedPaidMinor - refundedMinor); + const paymentStatus = String(b.payment_status || ''); + const effectiveCollectedMinor = Math.max( + netCollectedMinor, + paymentStatus === 'paid' ? Math.max(0, totalMinor - refundedMinor) : 0, + ); + const hasCollectedFunds = + effectiveCollectedMinor > 0 || + (recordedPaidMinor > 0 && paymentStatus !== 'refunded') || + paymentStatus === 'partial' || + paymentStatus === 'partially_refunded' || + (paymentStatus === 'paid' && (totalMinor > 0 || parsedPaymentDetails.length > 0)); + const isPayable = totalMinor > 0 || storedBalanceMinor > 0; + const unpaidBalanceMinor = isPayable + ? Math.max(storedBalanceMinor, Math.max(0, totalMinor - recordedPaidMinor)) + : 0; + const isSettled = + !isPayable || + (paymentStatus === 'paid' && + unpaidBalanceMinor === 0 && + recordedPaidMinor >= totalMinor); + return { + id: b.id, + paymentStatus, + totalMinor, + unroundedTotalMinor, + recordedPaidMinor, + effectiveCollectedMinor, + hasCollectedFunds, + isPayable, + unpaidBalanceMinor, + isSettled, + }; + }); + + const orderTotalMinor = Math.max( + 0, + Math.round(Number(currentOrder.total || 0) * minorFactor), + ); + const orderRoundOffMinor = Math.round(Number(currentOrder.round_off || 0) * minorFactor); + const orderUnroundedTotalMinor = Math.max(0, orderTotalMinor - orderRoundOffMinor); + const effectiveOrderTargetMinor = Math.min(orderTotalMinor, orderUnroundedTotalMinor); + const payableBills = evaluatedBills.filter((b) => b.isPayable); + const billedCoverageMinor = evaluatedBills.reduce( + (sum, b) => sum + Math.max(b.totalMinor, b.unroundedTotalMinor), + 0, + ); + const unbilledMinor = + payableBills.length === 0 + ? orderTotalMinor + : Math.max(0, effectiveOrderTargetMinor - billedCoverageMinor); + const totalOutstandingMinor = + payableBills.reduce((sum, b) => sum + b.unpaidBalanceMinor, 0) + unbilledMinor; + const totalCollectedMinor = evaluatedBills.reduce( + (sum, b) => sum + b.effectiveCollectedMinor, + 0, + ); + + const paymentBlocksCompletion = + status === 'completed' && + (totalOutstandingMinor > 0 || + (orderTotalMinor > 0 && payableBills.length === 0) || + payableBills.some((b) => !b.isSettled)); + + const requireKitchenDelivered = db + .prepare( + "SELECT value FROM settings WHERE key = 'require_kitchen_delivered_before_settlement'", + ) + .get() as { value: string } | undefined; + const kdsEnabled = db + .prepare("SELECT value FROM settings WHERE key = 'kds_enabled'") + .get() as { value: string } | undefined; + const billingType = db + .prepare("SELECT value FROM settings WHERE key = 'billing_type'") + .get() as { value: string } | undefined; + const kitchenPolicyApplies = + requireKitchenDelivered?.value === 'true' && + kdsEnabled?.value !== 'false' && + billingType?.value !== 'prepaid'; + const undeliveredKitchenItems = + status === 'completed' && kitchenPolicyApplies + ? (db + .prepare(` + SELECT product_name FROM order_items + WHERE order_id = ? AND status IN ('pending', 'preparing', 'ready') + `) + .all(req.params.id) as { product_name: string }[]) + : []; + const kitchenBlocksCompletion = + status === 'completed' && undeliveredKitchenItems.length > 0; + + const orderInProgressForCancel = currentStatusIndex > 0 || hasItemsInProgress; + const hasCollectedFundsOnOrder = evaluatedBills.some((b) => b.hasCollectedFunds); + const requiresCancelOverride = + status === 'cancelled' && (orderInProgressForCancel || hasCollectedFundsOnOrder); + const requiresCompleteOverride = + status === 'completed' && (paymentBlocksCompletion || kitchenBlocksCompletion); + const requiresOverride = requiresCancelOverride || requiresCompleteOverride; + + const overriddenSafeguards: string[] = []; + if (status === 'completed') { + if (paymentBlocksCompletion) overriddenSafeguards.push('unsettled_payment'); + if (kitchenBlocksCompletion) overriddenSafeguards.push('kitchen_undelivered'); + } else if (status === 'cancelled') { + if (orderInProgressForCancel) overriddenSafeguards.push('order_in_progress'); + if (hasCollectedFundsOnOrder) overriddenSafeguards.push('collected_funds'); + } let approvedByUserId: string | undefined; if (requiresOverride) { - if (!override_pin) { - throw Object.assign(new Error('Manager PIN required to cancel order in progress'), { - statusCode: 400, - }); + if (!override_pin || typeof override_pin !== 'string') { + let missingPinMessage = 'Manager PIN required to cancel order in progress'; + if (status === 'cancelled' && hasCollectedFundsOnOrder && !orderInProgressForCancel) { + missingPinMessage = 'Manager PIN required to cancel order with collected payment'; + } else if (status === 'completed') { + if (paymentBlocksCompletion && kitchenBlocksCompletion) { + missingPinMessage = + 'Manager PIN required to complete unpaid order with undelivered kitchen items'; + } else if (paymentBlocksCompletion) { + missingPinMessage = 'Manager PIN required to complete unpaid order'; + } else { + missingPinMessage = + 'Manager PIN required to complete order with undelivered kitchen items'; + } + } + throw Object.assign(new Error(missingPinMessage), { statusCode: 400 }); } const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; - // Rate-limit cancellation attempts per client. - const rateLimitKey = `pin:${clientIp}:order-cancel`; - if (!checkPinRateLimit(rateLimitKey)) { + const rateLimitKey = + status === 'cancelled' + ? `pin:${clientIp}:order-cancel` + : `pin:${clientIp}:order-complete`; + if (isPinRateLimited(rateLimitKey)) { throw Object.assign(new Error('Too many PIN attempts. Try again in 15 minutes.'), { statusCode: 429, }); } - const user = ( - db - .prepare('SELECT * FROM users WHERE is_active = 1 AND pin_hash IS NOT NULL') - .all() as any[] - ).find( + 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 PinApproverCandidate[]) + : (db + .prepare('SELECT * FROM users WHERE is_active = 1 AND pin_hash IS NOT NULL') + .all() as PinApproverCandidate[]); + + const user = candidateUsers.find( (candidate) => hasRole(candidate.role, ROLE_ACCESS.ownerManager) && - hasPermission(candidate.id, 'orders.item.cancel') && + hasPermission(candidate.id, 'orders.status.update') && + (status !== 'cancelled' || hasPermission(candidate.id, 'orders.item.cancel')) && + (!paymentBlocksCompletion || hasPermission(candidate.id, 'payments.take')) && verifyPin(candidate.pin_hash, override_pin), ); if (!user) { + checkPinRateLimit(rateLimitKey); throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); } + pinAttempts.delete(rateLimitKey); approvedByUserId = user.id; } @@ -2194,11 +2387,11 @@ router.patch( break; case 'cancelled': { - // Select only items eligible for restocking (exclude already cancelled, voided, or accounting adjustments) + // Restore stock only for items still pending; prepared/in-progress items use void/waste semantics. const eligibleItems = db .prepare(` SELECT * FROM order_items - WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded') + WHERE order_id = ? AND status = 'pending' `) .all(req.params.id) as any[]; @@ -2259,7 +2452,23 @@ router.patch( details: { from: currentOrder.status, to: status, - ...(approvedByUserId && { approved_by: approvedByUserId }), + ...(approvedByUserId && { + actor_user_id: authUser.userId, + approved_by: approvedByUserId, + overridden_safeguards: overriddenSafeguards, + bill_ids: evaluatedBills.map((b) => b.id), + outstanding_amount: roundCurrency(totalOutstandingMinor / minorFactor), + outstanding_amounts: evaluatedBills.map((b) => ({ + bill_id: b.id, + outstanding_amount: roundCurrency(b.unpaidBalanceMinor / minorFactor), + collected_amount: roundCurrency(b.effectiveCollectedMinor / minorFactor), + })), + collected_amount: roundCurrency(totalCollectedMinor / minorFactor), + ...(kitchenBlocksCompletion + ? { undelivered_items: undeliveredKitchenItems.map((i) => i.product_name) } + : {}), + }), + ...(status === 'cancelled' && reason ? { reason } : {}), }, }); diff --git a/tests/addon-inventory-lifecycle.test.ts b/tests/addon-inventory-lifecycle.test.ts index d059c077f..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,30 +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; + 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, + ); + 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' }, + }); + 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', + ); } // ── 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 @@ -282,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); @@ -333,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(); @@ -356,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/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'); } diff --git a/tests/cancel-override.test.ts b/tests/cancel-override.test.ts index aab717f94..225291554 100644 --- a/tests/cancel-override.test.ts +++ b/tests/cancel-override.test.ts @@ -42,7 +42,7 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { initDatabase, getDatabase, closeDatabase, now } = require('../main/db'); const { getJWTSecret } = require('../main/routes/auth'); -const { orderRoutes } = require('../main/routes/orders'); +const { orderRoutes, resetPinRateLimitForTests } = require('../main/routes/orders'); // ── Test Helpers ────────────────────────────────────────────────────────────── @@ -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 }, @@ -346,6 +387,178 @@ async function main() { }); assertEqual(res.status, 400, 'returns 400 for invalid status'); } + + // ── Test 9: Cancel pending order with collected funds requires PIN (#1057) ── + console.log('\n9. Cancel pending order with collected funds requires PIN'); + { + resetPinRateLimitForTests(); + 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, ?, ?)`, + ).run(now(), now()); + 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', ?, ?)`, + ).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, ?, ?, ?)`, + ).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 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', + ); + + 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', + ); + + 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', + }), + }); + 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 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', + ); + } + + // ── 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', + ); + { + 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)`, + ).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, ?, ?)`, + ).run(now(), now()); + 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', ?, ?)`, + ).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', ?, ?)`, + ).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', + }), + }); + 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', + ); + } } finally { server.close(); closeDatabase(); diff --git a/tests/issue-252-order-lifecycle-inventory.test.ts b/tests/issue-252-order-lifecycle-inventory.test.ts index 11f1af0c2..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' } }); + 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,30 +399,47 @@ 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', headers: authHeader, - body: { status: 'completed' }, + body: { status: 'completed', override_pin: '1234' }, }); 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 3ab68ad78..974908b51 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,10 +20,17 @@ 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 } = require('../main/routes/orders'); +const { orderRoutes, resetPinRateLimitForTests } = require('../main/routes/orders'); const { registerRoutes } = require('../main/routes/index'); function seedUser(db: any, id: string, role: string, pin?: string) { @@ -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,67 +112,567 @@ 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, + ); + + const ownerUnbilledNoPin = await api(baseUrl, `/api/orders/${unbilledOrder.orderId}/status`, { + 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', + ); + + 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, + ); + const unpaidBillRes = await api(baseUrl, '/api/bills/generate', { + 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', + ); + + // 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', + ); + + // 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()); + 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', + ); + } finally { + 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, + }); + 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', + ); + + resetPinRateLimitForTests(); + 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 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', + ); + + // 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, + }); + 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', + ); + + // 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, + ); + const settledBillGen = await api(baseUrl, '/api/bills/generate', { + 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, + }); + 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, + ); + const settledComplete = await api(baseUrl, `/api/orders/${settledOrder.orderId}/status`, { + method: 'PATCH', + body: { status: 'completed' }, + headers: cashierAuth, + }); + 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', ?) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = excluded.updated_at`).run(now()); + db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES ('kds_enabled', 'true', ?) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = excluded.updated_at`).run(now()); + 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, + }); + 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 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', + ); + } finally { + 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( + `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, ?, ?, ?)`, + ).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, '[]', ?, ?)`, + ).run(splitOrder.orderId, now(), now()); + + const halfPaidSplitComplete = await api(baseUrl, `/api/orders/${splitOrder.orderId}/status`, { + method: 'PATCH', + body: { status: 'completed' }, + headers: ownerAuth, + }); + 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 }])); + const fullPaidSplitComplete = await api(baseUrl, `/api/orders/${splitOrder.orderId}/status`, { + method: 'PATCH', + body: { status: 'completed' }, + headers: cashierAuth, + }); + assertEqual( + fullPaidSplitComplete.status, + 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(); @@ -146,4 +683,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 3e471af07..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,45 @@ 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', + headers: authHeader, + body: { + type: 'takeaway', + items: [ + { product_id: 'prod-latte', quantity: 1 }, + { product_id: 'prod-latte', quantity: 2 }, + ], + }, + }); + assertEqual(mixedRecipeOrder.status, 201, 'mixed recipe order created'); + const beansAfterMixedCreate = stockOf(db, beans.id); + await api(baseUrl, `/api/order-items/${mixedRecipeOrder.data.order.items[0].id}/status`, { + method: 'PATCH', + 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' }, + }, + ); + 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)', + ); console.log('\n--- 7. Inactive recipe skips depletion ---'); saveRecipe(db, { @@ -226,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 ---'); @@ -242,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, @@ -258,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)', ); @@ -283,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), @@ -300,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' }], @@ -316,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', @@ -344,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,