From c9d5b0538d7740a3c82badcdbe36cfaa8e4dedc7 Mon Sep 17 00:00:00 2001
From: khaira777 <777gurkirat@gmail.com>
Date: Wed, 7 Oct 2026 01:50:13 -0400
Subject: [PATCH 1/5] fix: restore post-3.12 recovery, held-cart, menu, and
expected-method behavior
Window load recovery destroyed the last window before its replacement
existed, so the Windows/Linux all-closed listener quit the app mid-recovery;
the guard now lives in a testable module and is set before the destroy.
Holds lost the cashier's waived and opted-in charge decisions: two additive
held_orders columns, validated bounded-id API fields, store/cart restoration,
and POS/Orders resume wiring carry them through hold and reload.
Menus advertised an unsellable parent price while selling variants, on both
the thermal document and the paper/PDF HTML, so active variants now expand
into their own sale rows with POS stock and recipe-link semantics.
Remote updates and backup polling refreshed only page one, leaving loaded
history stale; both triggers now refresh every loaded page.
A configured collection method named Pending or Unknown collided with the
builtin sentinels. Orders store an additive, non-FK identity column beside
the existing name snapshot, and the delivery slip prints a configured
method's stored name literally while sentinels stay localized.
---
docs/architecture/printing.md | 13 +-
docs/reference/api.md | 6 +-
.../prepaid-payment-reconciliation.spec.ts | 7 +-
frontend/src/app/(dashboard)/orders/page.tsx | 24 ++-
frontend/src/app/(dashboard)/pos/page.tsx | 24 ++-
frontend/src/components/pos/CartPanel.tsx | 42 ++++-
.../components/products/PrintMenuModal.tsx | 33 +++-
.../src/lib/printer/delivery-slip-encoder.ts | 2 +-
.../lib/printer/delivery-slip-web-print.ts | 2 +-
frontend/src/lib/types.ts | 2 +
frontend/src/store/cart.ts | 36 +++-
frontend/src/store/held-orders.ts | 47 ++++-
main/db.ts | 30 +++
main/index.ts | 42 ++---
main/printers/document-delivery-slip.ts | 6 +
main/routes/held-orders.ts | 56 +++++-
main/routes/orders-validation.ts | 28 +++
main/routes/orders.ts | 27 ++-
main/routes/printers.ts | 51 ++++--
main/window-recovery.ts | 84 +++++++++
shared/print/document.ts | 15 +-
tests/cart-variant-identity.test.ts | 39 ++++
tests/delivery-address-egress.test.ts | 155 ++++++++++++++++
tests/delivery-slip-printing.test.ts | 134 +++++++++++++-
tests/held-orders-store.test.ts | 85 ++++++++-
tests/held-orders.test.ts | 126 +++++++++++++
tests/menu-printing.test.ts | 48 ++++-
tests/shutdown-lifecycle.test.ts | 171 ++++++++++++++++++
tests/upgrade-path.test.ts | 130 +++++++++++++
29 files changed, 1363 insertions(+), 102 deletions(-)
create mode 100644 main/window-recovery.ts
diff --git a/docs/architecture/printing.md b/docs/architecture/printing.md
index be552855d..040f67c2b 100644
--- a/docs/architecture/printing.md
+++ b/docs/architecture/printing.md
@@ -65,7 +65,11 @@ total with zero due, or the selected bill balance(s) as amount due. Refund statu
does not hide a refund-marked bill's positive stored balance; when no bill exists, the slip falls
back to the order total. An unpaid balance also names the order's `expected_payment_method`: Cash on
Delivery for cash, otherwise the method, Pending, or Unknown when none was recorded. It is an
-expectation, never a payment, and a paid slip shows the captured method instead. The notes block
+expectation, never a payment, and a paid slip shows the captured method instead. When the order
+carries an `expected_payment_method_id`, the stored name is the historical snapshot of a configured
+method and prints literally instead of resolving through a builtin or sentinel label, so a configured
+method named Pending or Unknown keeps its own meaning on the slip even when the method is later
+renamed or deactivated. The notes block
carries the courier-only `delivery_note` beside the order note; neither the delivery note nor the
expected method is printed on a kitchen ticket or receipt. It is a **separate kind, not a receipt
template**, and that is load-bearing. It carries the delivery address and the full customer number
@@ -265,6 +269,13 @@ KOT path applies it, so an item that has left the kitchen does not reappear on a
## Menu catalog printing
+A product with active variants prints one sale row per active variant, in the catalog's variant
+order, named `Parent (Variant)` at the variant's own price, so the menu never advertises the
+parent's unsellable price. A product with no active variants keeps its single parent row. A
+variant that links to a recipe ingredient is not gated by its own pool, and `includeOutOfStock`
+decides whether a sold-out variant row is emitted; the printed item count counts emitted rows on
+every destination.
+
Products and POS expose a filtered menu print dialog with optional descriptions and effective
add-on groups. The dialog can send ESC/POS to a selected receipt printer and roll width, open the
system print dialog for A4 or Letter paper, or save a structured PDF. In Electron, the PDF uses the
diff --git a/docs/reference/api.md b/docs/reference/api.md
index 7f8d50715..fccb8ca77 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -266,7 +266,7 @@ Router: `main/routes/orders.ts`. Full path: `/api/orders`.
| --- | --- | --- | --- | --- |
| `GET` | `/` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | query: `?status`, `?type`, `?today`, `?start_date`, `?end_date`, `?table_id`, `?before_id`, `?per_page`, `?search` | `{ orders, nextCursor? }`, newest page first. `?before_id` pages backwards. `?search` matches order number, customer name, or phone before pagination. |
| `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`, `delivery_note`; header: `Idempotency-Key` | `201` with the created order. Honours an `Idempotency-Key` header; reusing a key with a different body returns `409`. `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: the method is `unknown` (also the default), `pending`, `cash`, `card`, or an active custom payment method name, and is never recorded as a payment; `400` for any other method or a note over 200 characters. 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` | `/` | `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. Honours an `Idempotency-Key` header; reusing a key with a different body returns `409`. `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: the method is `unknown` (also the default), `pending`, `cash`, `card`, or an active custom payment method name, and is never recorded as a payment; `400` for any other method or a note over 200 characters. An optional `expected_payment_method_id` names an **active** configured method and is the durable identity: the server stores that method's canonical name, requires any supplied name to match it case-insensitively, and `400`s an unknown, inactive, or non-numeric id. It 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 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/customer` | `ROLE_ACCESS.ownerManager` + order write limiter | path: `id`; body: `customer_id` | - |
@@ -313,8 +313,8 @@ Router: `main/routes/held-orders.ts`. Full path: `/api/held-orders`.
| Method | Path | Authorization | Parameters | Response |
| --- | --- | --- | --- | --- |
-| `GET` | `/` | `ROLE_ACCESS.sales` + held-order read limiter | none | - |
-| `POST` | `/` | `ROLE_ACCESS.sales` + held-order write limiter | none | - |
+| `GET` | `/` | `ROLE_ACCESS.sales` + held-order read limiter | none | Held carts in `updated_at` order, each with `waivedChargeIds` and `optedInChargeIds`; a hold saved without them reads back as an empty list. A row whose stored selection JSON is damaged is still returned with empty selections. |
+| `POST` | `/` | `ROLE_ACCESS.sales` + held-order write limiter | body: held-cart fields plus optional `waivedChargeIds`, `optedInChargeIds` | `{ success, id }`. Selections are bounded charge ids (`CHARGE_ID_PATTERN`, `MAX_CHARGE_ID_LENGTH`) and are deduplicated; an omitted, null, or empty list means no choices, and a malformed list is a `400` that leaves the stored cart untouched. Unknown but well-formed ids persist, because the charges engine decides applicability when the order is created. |
| `DELETE` | `/:tableId` | `ROLE_ACCESS.sales` + held-order write limiter | path: `tableId`; query: `?heldOrderId` | `?heldOrderId` selects one of several holds on the table. |
### Bills
diff --git a/frontend/e2e/prepaid-payment-reconciliation.spec.ts b/frontend/e2e/prepaid-payment-reconciliation.spec.ts
index 69af69c6b..54700433a 100644
--- a/frontend/e2e/prepaid-payment-reconciliation.spec.ts
+++ b/frontend/e2e/prepaid-payment-reconciliation.spec.ts
@@ -581,8 +581,11 @@ test('payment modal hides charge controls without bill discount permission', asy
await serverEmail.fill('server@flo.local');
await serverPage!.locator('#password').fill(E2E_PASSWORD);
await serverPage!.locator('button[type="submit"]').click();
- await serverPage!.waitForURL((url) => url.pathname !== '/auth/login' && url.pathname !== '/auth/login/');
- await serverPage!.goto(`${BASE}/orders`);
+ // Sign-in already lands this restricted staff member on Orders, so wait for
+ // the intended page instead of navigating again: a second full navigation
+ // mounts the page twice and doubles its per-mount settings reads.
+ await serverPage!.waitForURL((url) => url.pathname.replace(/\/+$/, '') === '/orders');
+ await expect(serverPage!.getByPlaceholder(/search/i).first()).toBeVisible();
await serverPage!.getByPlaceholder(/search/i).first().fill(order.order_number);
await expect(serverPage!.getByText(`#${order.order_number}`)).toBeVisible();
markStage('staff_login_order_search', 'complete');
diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx
index 875f3c435..171e9f8f2 100644
--- a/frontend/src/app/(dashboard)/orders/page.tsx
+++ b/frontend/src/app/(dashboard)/orders/page.tsx
@@ -444,7 +444,9 @@ export default function OrdersPage() {
try {
const data = JSON.parse(event.data);
if (data.type === 'order_updated' || data.type === 'orders' || data.type === 'initial_data') {
- fetchOrders(undefined, { rateLimitedRefresh: true });
+ // Another terminal just changed an order this page has loaded, so
+ // the already-visible pages are refreshed, not only page one.
+ fetchOrders(undefined, { rateLimitedRefresh: true, refreshLoadedPages: true });
}
} catch {
// Ignore parse errors
@@ -525,8 +527,13 @@ export default function OrdersPage() {
initPage();
- // 10-second backup polling interval (WebSocket handles real-time updates)
- const interval = setInterval(() => fetchOrders(undefined, { rateLimitedRefresh: true }), 10000);
+ // 10-second backup polling interval (WebSocket handles real-time updates).
+ // It is also the only trigger when the socket is unavailable, so it carries
+ // the same loaded-page refresh as the live push.
+ const interval = setInterval(
+ () => fetchOrders(undefined, { rateLimitedRefresh: true, refreshLoadedPages: true }),
+ 10000,
+ );
return () => {
clearInterval(interval);
@@ -1193,7 +1200,16 @@ export default function OrdersPage() {
try {
const held = await heldOrdersStore.restoreOrder(heldOrder.tableId);
if (held) {
- cartStore.loadItems(held.items, heldOrder.tableId, held.customerId, held.guestCount, held.orderNotes, held.id);
+ cartStore.loadItems(
+ held.items,
+ heldOrder.tableId,
+ held.customerId,
+ held.guestCount,
+ held.orderNotes,
+ held.id,
+ held.waivedChargeIds,
+ held.optedInChargeIds,
+ );
cartStore.setOrderType('dine_in');
router.push('/pos');
} else {
diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx
index cce730e10..3586e2b67 100644
--- a/frontend/src/app/(dashboard)/pos/page.tsx
+++ b/frontend/src/app/(dashboard)/pos/page.tsx
@@ -676,6 +676,7 @@ export default function POSPage() {
external_order_id: cart.orderType === 'online' ? cart.externalOrderId || undefined : undefined,
delivery_address: cart.orderType === 'delivery' ? cart.deliveryAddress || undefined : undefined,
expected_payment_method: cart.orderType === 'delivery' ? cart.expectedPaymentMethod || undefined : undefined,
+ expected_payment_method_id: cart.orderType === 'delivery' ? cart.expectedPaymentMethodId ?? undefined : undefined,
delivery_note: cart.orderType === 'delivery' ? cart.deliveryNote || undefined : undefined,
waived_charge_ids: Array.from(cart.waivedChargeIds),
opted_in_charge_ids: Array.from(cart.optedInChargeIds),
@@ -746,6 +747,7 @@ export default function POSPage() {
external_order_id: cart.orderType === 'online' ? cart.externalOrderId : undefined,
delivery_address: cart.orderType === 'delivery' ? cart.deliveryAddress || undefined : undefined,
expected_payment_method: cart.orderType === 'delivery' ? cart.expectedPaymentMethod || undefined : undefined,
+ expected_payment_method_id: cart.orderType === 'delivery' ? cart.expectedPaymentMethodId ?? undefined : undefined,
delivery_note: cart.orderType === 'delivery' ? cart.deliveryNote || undefined : undefined,
items: orderItems,
waived_charge_ids: Array.from(cart.waivedChargeIds),
@@ -835,6 +837,7 @@ export default function POSPage() {
external_order_id: cart.orderType === 'online' ? cart.externalOrderId || undefined : undefined,
delivery_address: cart.orderType === 'delivery' ? cart.deliveryAddress || undefined : undefined,
expected_payment_method: cart.orderType === 'delivery' ? cart.expectedPaymentMethod || undefined : undefined,
+ expected_payment_method_id: cart.orderType === 'delivery' ? cart.expectedPaymentMethodId ?? undefined : undefined,
delivery_note: cart.orderType === 'delivery' ? cart.deliveryNote || undefined : undefined,
waived_charge_ids: Array.from(cart.waivedChargeIds),
opted_in_charge_ids: Array.from(cart.optedInChargeIds),
@@ -975,7 +978,16 @@ export default function POSPage() {
try {
const held = await heldOrders.restoreOrder(tableId);
if (held) {
- cart.loadItems(held.items, tableId, held.customerId, held.guestCount, held.orderNotes, held.id);
+ cart.loadItems(
+ held.items,
+ tableId,
+ held.customerId,
+ held.guestCount,
+ held.orderNotes,
+ held.id,
+ held.waivedChargeIds,
+ held.optedInChargeIds,
+ );
cart.setOrderType('dine_in');
} else {
await heldOrders.fetchHeldOrders();
@@ -996,7 +1008,15 @@ export default function POSPage() {
}
const tableName = tables.find((t) => t.id === tableId)?.name || tableId;
try {
- await heldOrders.holdOrder(tableId, cart.items, cart.customerId, cart.guestCount, cart.orderNotes);
+ await heldOrders.holdOrder(
+ tableId,
+ cart.items,
+ cart.customerId,
+ cart.guestCount,
+ cart.orderNotes,
+ [...cart.waivedChargeIds],
+ [...cart.optedInChargeIds],
+ );
cart.clearCart();
setShowTablePicker(false);
toast.success(t('orderHeld', { tableName }));
diff --git a/frontend/src/components/pos/CartPanel.tsx b/frontend/src/components/pos/CartPanel.tsx
index 8ca32bab9..4ff05993a 100644
--- a/frontend/src/components/pos/CartPanel.tsx
+++ b/frontend/src/components/pos/CartPanel.tsx
@@ -144,6 +144,7 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
const t = useTranslations('pos');
const tCommon = useTranslations('common');
const tOrders = useTranslations('orders');
+ const tSettings = useTranslations('settings');
const isRestaurant = (currentTenant?.business_type ?? 'restaurant') === 'restaurant';
const fmt = useFormatCurrency();
const canHold = isRestaurant && cart.orderType === 'dine_in' && cart.tableId && cart.items.length > 0 && billingType === 'postpaid';
@@ -195,7 +196,15 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
}
const tableName = tables.find((t) => t.id === cart.tableId)?.name || cart.tableId;
try {
- await heldOrders.holdOrder(cart.tableId, cart.items, cart.customerId, cart.guestCount, cart.orderNotes);
+ await heldOrders.holdOrder(
+ cart.tableId,
+ cart.items,
+ cart.customerId,
+ cart.guestCount,
+ cart.orderNotes,
+ [...cart.waivedChargeIds],
+ [...cart.optedInChargeIds],
+ );
cart.clearCart();
toast.success(t('orderHeldFor', { table: tableName }));
} catch {
@@ -269,17 +278,38 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
{t('expectedPayment')}
cart.setExpectedPaymentMethod(e.target.value)}
+ value={cart.expectedPaymentMethodId !== null
+ ? `custom:${cart.expectedPaymentMethodId}`
+ : cart.expectedPaymentMethod}
+ onChange={(e) => {
+ const selected = e.target.value;
+ // A configured method travels by identity, so a method named
+ // "Pending" or "Unknown" cannot be read back as a sentinel.
+ const customId = selected.startsWith('custom:')
+ ? Number(selected.slice('custom:'.length))
+ : null;
+ if (customId !== null) {
+ const method = customPaymentMethods.find((candidate) => candidate.id === customId);
+ if (method) {
+ cart.setExpectedPaymentMethod(method.name, method.id);
+ return;
+ }
+ }
+ cart.setExpectedPaymentMethod(selected, null);
+ }}
className="flex-1 min-w-0 min-h-11 px-3 py-2 text-sm border border-border bg-card rounded-lg focus:ring-2 focus:ring-brand focus:border-brand outline-none"
>
{tCommon('unknown')}
{tOrders('pending')}
{t('methodCash')}
{t('methodCard')}
- {customPaymentMethods.map((method) => (
- {method.name}
- ))}
+ {customPaymentMethods.length > 0 && (
+
+ {customPaymentMethods.map((method) => (
+ {method.name}
+ ))}
+
+ )}
diff --git a/frontend/src/components/products/PrintMenuModal.tsx b/frontend/src/components/products/PrintMenuModal.tsx
index fa8555ad9..1307246d7 100644
--- a/frontend/src/components/products/PrintMenuModal.tsx
+++ b/frontend/src/components/products/PrintMenuModal.tsx
@@ -19,7 +19,8 @@ import { usePrinterStore, type HardwarePrinter } from '@/hooks/usePrinter';
import { printerService } from '@/lib/printer/PrinterService';
import { useFormatCurrency } from '@/hooks/useFormatCurrency';
import { formatDateForTenant } from '@/lib/countries';
-import type { Category, Product } from '@/lib/types';
+import type { Category, Product, ProductVariant } from '@/lib/types';
+import { activeVariants, isVariantSoldOut } from '@/lib/product-variants';
import { buildMenuWebPrintHtml, MenuPopupBlockedError, printMenuInBrowser, reservePrintGesture, type MenuWebPrintSection } from '@/lib/printer/menu-web-print';
interface Props {
@@ -120,13 +121,25 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) {
const products = (productResponse.data.products || []) as Product[];
const categories = (categoryResponse.data.categories || []) as Category[];
const categoryById = new Map(categories.map((category) => [category.id, category]));
- const included = products.filter((product) =>
- (selectedFilters.includeInactive || product.is_active)
- && (selectedFilters.includeOutOfStock || !isOutOfStock(product))
- && (selectedFilters.includeHidden || !product.category_id || categoryById.get(product.category_id)?.is_active === true));
- const toPrintProduct = (product: Product) => ({
- name: product.name,
- price: formatCurrency(Number(product.price)),
+ // A product with active variants prints one row per variant at the
+ // variant's own price; the parent's unsellable price never reaches paper.
+ // A product without active variants keeps its single parent row.
+ const included = products
+ .filter((product) =>
+ (selectedFilters.includeInactive || product.is_active)
+ && (selectedFilters.includeHidden || !product.category_id || categoryById.get(product.category_id)?.is_active === true))
+ .flatMap((product) => {
+ const variants = activeVariants(product.variants);
+ if (variants.length === 0) {
+ return selectedFilters.includeOutOfStock || !isOutOfStock(product) ? [{ product }] : [];
+ }
+ return variants
+ .filter((variant) => selectedFilters.includeOutOfStock || !isVariantSoldOut(variant))
+ .map((variant) => ({ product, variant }));
+ });
+ const toPrintProduct = ({ product, variant }: { product: Product; variant?: ProductVariant }) => ({
+ name: variant ? `${product.name} (${variant.name})` : product.name,
+ price: formatCurrency(Number(variant ? variant.price : product.price)),
details: [
...(selectedFilters.includeDescriptions && product.description ? [product.description] : []),
...(selectedFilters.includeModifiers ? (product.addon_groups || []).map((group) =>
@@ -137,12 +150,12 @@ export default function PrintMenuModal({ open, onOpenChange }: Props) {
.map((category) => ({
name: category.name,
products: included
- .filter((product) => product.category_id === category.id)
+ .filter((row) => row.product.category_id === category.id)
.map(toPrintProduct),
}))
.filter((section) => section.products.length > 0);
const uncategorized = included
- .filter((product) => !product.category_id || !categoryById.has(product.category_id))
+ .filter((row) => !row.product.category_id || !categoryById.has(row.product.category_id))
.map(toPrintProduct);
if (uncategorized.length > 0) sections.push({ name: null, products: uncategorized });
const itemCount = sections.reduce((total, section) => total + section.products.length, 0);
diff --git a/frontend/src/lib/printer/delivery-slip-encoder.ts b/frontend/src/lib/printer/delivery-slip-encoder.ts
index 897c7f115..06f19c02c 100644
--- a/frontend/src/lib/printer/delivery-slip-encoder.ts
+++ b/frontend/src/lib/printer/delivery-slip-encoder.ts
@@ -162,7 +162,7 @@ export function buildDeliverySlipBytes(
const summary = isPaid
? `${status}: ${method ? `${method} ` : ''}(${label('pos.total')}: ${payment.formattedAmount})`
: isCollectible
- ? `${status}: ${payment.formattedAmount} (${deliverySlipExpectedPaymentText(payment.expectedMethod, label)})`
+ ? `${status}: ${payment.formattedAmount} (${deliverySlipExpectedPaymentText(payment.expectedMethod, label, payment.expectedMethodIsCustom === true)})`
: payment.status === 'unpaid'
? `${status}: ${payment.formattedAmount}`
: status;
diff --git a/frontend/src/lib/printer/delivery-slip-web-print.ts b/frontend/src/lib/printer/delivery-slip-web-print.ts
index c566f7ead..53496a328 100644
--- a/frontend/src/lib/printer/delivery-slip-web-print.ts
+++ b/frontend/src/lib/printer/delivery-slip-web-print.ts
@@ -91,7 +91,7 @@ export function generateDeliverySlipHtml(
: isPaid
? `
${escapeHtml(tr('print.deliverySlip.paid'))}: ${escapeHtml(paymentMethod)} (${escapeHtml(tr('pos.total'))}: ${escapeHtml(payment.formattedAmount)})
${payment.formattedAmountDue ? `
${escapeHtml(tr('print.deliverySlip.amountDue'))}: ${escapeHtml(payment.formattedAmountDue)}
` : ''}
`
: isCollectible
- ? `
${escapeHtml(tr('print.deliverySlip.toCollect'))}: ${escapeHtml(payment.formattedAmount)} (${escapeHtml(deliverySlipExpectedPaymentText(payment.expectedMethod, tr))})
`
+ ? `
${escapeHtml(tr('print.deliverySlip.toCollect'))}: ${escapeHtml(payment.formattedAmount)} (${escapeHtml(deliverySlipExpectedPaymentText(payment.expectedMethod, tr, payment.expectedMethodIsCustom === true))})
`
: payment.status === 'unpaid'
? `
${escapeHtml(tr('print.deliverySlip.amountDue'))}: ${escapeHtml(payment.formattedAmount)}
`
: `
${escapeHtml(tr(payment.status === 'refunded' ? 'print.deliverySlip.refunded' : 'print.deliverySlip.partiallyRefunded'))}
${escapeHtml(tr('print.deliverySlip.amountDue'))}: ${escapeHtml(payment.formattedAmountDue ?? '')}
`;
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 15bba0095..567adba0f 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -198,6 +198,8 @@ export interface Order {
delivery_address?: string | null;
/** Method the courier expects to collect; null is unknown. Not a payment record. */
expected_payment_method?: string | null;
+ /** Historical configured-method identity; null for built-ins, sentinels, and legacy orders. */
+ expected_payment_method_id?: number | null;
/** Courier-only note, printed on the delivery slip. */
delivery_note?: string | null;
type: 'dine_in' | 'takeaway' | 'delivery' | 'online';
diff --git a/frontend/src/store/cart.ts b/frontend/src/store/cart.ts
index f72f06d93..1bdf1e3d2 100644
--- a/frontend/src/store/cart.ts
+++ b/frontend/src/store/cart.ts
@@ -19,6 +19,8 @@ interface CartState {
deliveryAddress: string;
/** '' is unknown; 'pending', 'cash', 'card', or a custom method name otherwise. */
expectedPaymentMethod: string;
+ /** Configured-method identity for a custom choice; built-ins and sentinels keep it null. */
+ expectedPaymentMethodId: number | null;
deliveryNote: string;
onlinePlatform: string;
externalOrderId: string;
@@ -29,7 +31,16 @@ interface CartState {
removeItem: (cartItemId: string) => void;
updateQuantity: (cartItemId: string, quantity: number) => void;
clearCart: () => void;
- loadItems: (items: CartItem[], tableId: string | null, customerId: number | string | null, guestCount: number, orderNotes?: string, heldOrderId?: string) => void;
+ loadItems: (
+ items: CartItem[],
+ tableId: string | null,
+ customerId: number | string | null,
+ guestCount: number,
+ orderNotes?: string,
+ heldOrderId?: string,
+ waivedChargeIds?: string[],
+ optedInChargeIds?: string[],
+ ) => void;
setOrderType: (type: CartState['orderType']) => void;
setTableId: (id: string | null) => void;
setCustomerId: (id: number | string | null) => void;
@@ -37,7 +48,7 @@ interface CartState {
setReservationCustomer: (customer: Customer | null) => void;
setGuestCount: (count: number) => void;
setDeliveryAddress: (address: string) => void;
- setExpectedPaymentMethod: (method: string) => void;
+ setExpectedPaymentMethod: (method: string, methodId?: number | null) => void;
setDeliveryNote: (note: string) => void;
setOnlinePlatform: (platform: string) => void;
setExternalOrderId: (id: string) => void;
@@ -65,6 +76,7 @@ export const useCartStore = create
((set, get) => ({
guestCount: 1,
deliveryAddress: '',
expectedPaymentMethod: '',
+ expectedPaymentMethodId: null,
deliveryNote: '',
onlinePlatform: '',
externalOrderId: '',
@@ -159,17 +171,29 @@ export const useCartStore = create((set, get) => ({
},
clearCart: () => {
- set({ items: [], tableId: null, heldOrderId: null, customerId: null, customer: null, customerSource: null, guestCount: 1, orderType: 'dine_in', deliveryAddress: '', expectedPaymentMethod: '', deliveryNote: '', onlinePlatform: '', externalOrderId: '', orderNotes: '', waivedChargeIds: new Set(), optedInChargeIds: new Set() });
+ set({ items: [], tableId: null, heldOrderId: null, customerId: null, customer: null, customerSource: null, guestCount: 1, orderType: 'dine_in', deliveryAddress: '', expectedPaymentMethod: '', expectedPaymentMethodId: null, deliveryNote: '', onlinePlatform: '', externalOrderId: '', orderNotes: '', waivedChargeIds: new Set(), optedInChargeIds: new Set() });
},
- loadItems: (items, tableId, customerId, guestCount, orderNotes, heldOrderId) => {
- set({ items: normalizeCartItems(items), tableId, heldOrderId: heldOrderId || null, customerId, customerSource: customerId == null ? null : 'explicit', guestCount, orderNotes: orderNotes || '', waivedChargeIds: new Set(), optedInChargeIds: new Set() });
+ loadItems: (items, tableId, customerId, guestCount, orderNotes, heldOrderId, waivedChargeIds, optedInChargeIds) => {
+ set({
+ items: normalizeCartItems(items),
+ tableId,
+ heldOrderId: heldOrderId || null,
+ customerId,
+ customerSource: customerId == null ? null : 'explicit',
+ guestCount,
+ orderNotes: orderNotes || '',
+ // Independent Sets: a resumed cart must not alias the arrays it came from.
+ waivedChargeIds: new Set(waivedChargeIds ?? []),
+ optedInChargeIds: new Set(optedInChargeIds ?? []),
+ });
},
setOrderType: (type) => set((state) => ({
orderType: type,
deliveryAddress: type !== 'delivery' ? '' : state.deliveryAddress,
expectedPaymentMethod: type !== 'delivery' ? '' : state.expectedPaymentMethod,
+ expectedPaymentMethodId: type !== 'delivery' ? null : state.expectedPaymentMethodId,
deliveryNote: type !== 'delivery' ? '' : state.deliveryNote,
onlinePlatform: type !== 'online' ? '' : state.onlinePlatform,
externalOrderId: type !== 'online' ? '' : state.externalOrderId,
@@ -185,7 +209,7 @@ export const useCartStore = create((set, get) => ({
setReservationCustomer: (customer) => set({ customer, customerId: customer?.id ?? null, customerSource: customer ? 'reservation' : null }),
setGuestCount: (count) => set({ guestCount: count }),
setDeliveryAddress: (address) => set({ deliveryAddress: address }),
- setExpectedPaymentMethod: (method) => set({ expectedPaymentMethod: method }),
+ setExpectedPaymentMethod: (method, methodId) => set({ expectedPaymentMethod: method, expectedPaymentMethodId: methodId ?? null }),
setDeliveryNote: (note) => set({ deliveryNote: note }),
setOnlinePlatform: (platform) => set({ onlinePlatform: platform }),
setExternalOrderId: (id) => set({ externalOrderId: id }),
diff --git a/frontend/src/store/held-orders.ts b/frontend/src/store/held-orders.ts
index eee3d3e34..050ea4872 100644
--- a/frontend/src/store/held-orders.ts
+++ b/frontend/src/store/held-orders.ts
@@ -11,13 +11,24 @@ export interface HeldOrder {
customerId: number | string | null;
guestCount: number;
orderNotes: string;
+ /** Charge choices saved with the cart; a hold written by an older build has none. */
+ waivedChargeIds?: string[];
+ optedInChargeIds?: string[];
heldAt: string;
}
interface HeldOrdersState {
orders: Record;
fetchHeldOrders: () => Promise;
- holdOrder: (tableId: string, items: CartItem[], customerId: number | string | null, guestCount: number, orderNotes?: string) => Promise;
+ holdOrder: (
+ tableId: string,
+ items: CartItem[],
+ customerId: number | string | null,
+ guestCount: number,
+ orderNotes?: string,
+ waivedChargeIds?: string[],
+ optedInChargeIds?: string[],
+ ) => Promise;
restoreOrder: (tableId: string) => Promise;
removeHeldOrder: (tableId: string, expectedHeldOrderId?: string) => Promise;
hasHeldOrder: (tableId: string) => boolean;
@@ -71,7 +82,13 @@ export const createHeldOrdersStore = (apiClient: HeldOrdersApiClient = api) => c
const { data } = await apiClient.get(HELD_ORDERS_ENDPOINT);
if (data && data.orders && requestSequence === fetchSequence) {
const fetchedOrders: Record = {};
- for (const order of data.orders) fetchedOrders[order.tableId] = order;
+ for (const order of data.orders) {
+ fetchedOrders[order.tableId] = {
+ ...order,
+ waivedChargeIds: Array.isArray(order.waivedChargeIds) ? order.waivedChargeIds : [],
+ optedInChargeIds: Array.isArray(order.optedInChargeIds) ? order.optedInChargeIds : [],
+ };
+ }
set((state) => {
const newOrders = { ...state.orders };
const tableIds = new Set([...Object.keys(state.orders), ...Object.keys(fetchedOrders)]);
@@ -89,14 +106,34 @@ export const createHeldOrdersStore = (apiClient: HeldOrdersApiClient = api) => c
}
},
- holdOrder: async (tableId, items, customerId, guestCount, orderNotes = '') => {
+ holdOrder: async (
+ tableId,
+ items,
+ customerId,
+ guestCount,
+ orderNotes = '',
+ waivedChargeIds: string[] = [],
+ optedInChargeIds: string[] = [],
+ ) => {
try {
- const { data } = await apiClient.post(HELD_ORDERS_ENDPOINT, { tableId, items, customerId, guestCount, orderNotes });
+ const { data } = await apiClient.post(HELD_ORDERS_ENDPOINT, {
+ tableId, items, customerId, guestCount, orderNotes, waivedChargeIds, optedInChargeIds,
+ });
markTableMutation(tableId);
set((state) => ({
orders: {
...state.orders,
- [tableId]: { id: data?.id, tableId, items, customerId, guestCount, orderNotes, heldAt: new Date().toISOString() },
+ [tableId]: {
+ id: data?.id,
+ tableId,
+ items,
+ customerId,
+ guestCount,
+ orderNotes,
+ waivedChargeIds: [...waivedChargeIds],
+ optedInChargeIds: [...optedInChargeIds],
+ heldAt: new Date().toISOString(),
+ },
},
}));
} catch (err) {
diff --git a/main/db.ts b/main/db.ts
index 0c810be99..b95a578e1 100644
--- a/main/db.ts
+++ b/main/db.ts
@@ -5746,6 +5746,36 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [
}
},
},
+ {
+ version: 105,
+ name: 'add_held_order_charge_selections',
+ up: () => {
+ // Additive only: a cart held before these columns existed carries no
+ // charge decisions, and an empty selection list says exactly that.
+ const heldOrderColumns = getColumns(db, 'held_orders');
+ const addHeldOrderColumn = (name: string, definition: string) => {
+ if (heldOrderColumns.includes(name)) return;
+ db.exec(`ALTER TABLE held_orders ADD COLUMN ${definition}`);
+ heldOrderColumns.push(name);
+ };
+ addHeldOrderColumn('waived_charge_ids', `waived_charge_ids TEXT NOT NULL DEFAULT '[]'`);
+ addHeldOrderColumn('opted_in_charge_ids', `opted_in_charge_ids TEXT NOT NULL DEFAULT '[]'`);
+ },
+ },
+ {
+ version: 106,
+ name: 'add_order_expected_payment_method_id',
+ up: () => {
+ // A historical identity marker, deliberately not a foreign key: deleting
+ // or renaming the configured method must never rewrite what an order
+ // said it expected. Rows that predate the column stay NULL - an old
+ // `expected_payment_method` string was never an identity and must not be
+ // guessed into one.
+ if (!getColumns(db, 'orders').includes('expected_payment_method_id')) {
+ db.exec('ALTER TABLE orders ADD COLUMN expected_payment_method_id INTEGER DEFAULT NULL');
+ }
+ },
+ },
];
function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void {
diff --git a/main/index.ts b/main/index.ts
index 851d4bc55..7cc1a6a9f 100644
--- a/main/index.ts
+++ b/main/index.ts
@@ -48,6 +48,7 @@ import {
isWindowRendererReady,
} from './window-readiness';
import { setupWindowLoadRetry } from './window-load-retry';
+import { createFailedWindowRecovery, shouldQuitOnAllWindowsClosed } from './window-recovery';
import { registerUsbDevicePermissions } from './usb-device-permissions';
import { probeBackendHealth } from './backend-health';
import {
@@ -345,8 +346,6 @@ let isQuitting = false;
let runtimeState: RuntimeState = 'starting';
let initializationPromise: Promise | null = null;
let activationPending = false;
-let windowLoadRecoveryAttempted = false;
-let windowRecoveryInProgress = false;
let runtimeRelaunchRequested = false;
// Last did-fail-load detail captured for diagnostic dialog; cleared on successful load.
let lastWindowLoadFailure: { errorCode: number; errorDescription: string; validatedURL?: string } | null = null;
@@ -416,25 +415,17 @@ function isFailedWindowDocument(window: BrowserWindow): boolean {
}
}
+const failedWindowRecovery = createFailedWindowRecovery({
+ getMainWindow: () => mainWindow,
+ isAborted: () => isQuitting || isShutdownRequested() || runtimeState === 'stopping',
+ isRuntimeHealthy: () => isRuntimeHealthy(runtimeState, getRuntimeServices(), isShutdownRequested()),
+ requestRelaunch: (reason) => requestRuntimeRelaunchOnce(reason),
+ createWindow,
+ logError: (message, error) => log.error(message, error),
+});
+
function recoverFailedWindow(failedWindow: BrowserWindow): void {
- if (isQuitting || isShutdownRequested() || runtimeState === 'stopping') return;
- if (mainWindow !== failedWindow) return;
- if (!isRuntimeHealthy(runtimeState, getRuntimeServices(), isShutdownRequested())) {
- requestRuntimeRelaunchOnce('window-load-retry-exhausted');
- return;
- }
- if (windowLoadRecoveryAttempted) {
- requestRuntimeRelaunchOnce('window-load-recovery-failed');
- return;
- }
- windowLoadRecoveryAttempted = true;
- try {
- if (!failedWindow.isDestroyed()) failedWindow.destroy();
- createWindow();
- } catch (error) {
- log.error('[Window] Window recreation failed:', error);
- requestRuntimeRelaunchOnce('window-load-recovery-create-failed');
- }
+ failedWindowRecovery.recover(failedWindow);
}
// Flag passed in argv to prevent infinite relaunch loops across process restarts.
@@ -732,7 +723,7 @@ function createWindow(): void {
});
mainWindow.webContents.once('did-finish-load', () => {
- windowLoadRecoveryAttempted = false;
+ failedWindowRecovery.markLoadSucceeded();
lastWindowLoadFailure = null;
clearRendererStabilityResetTimer();
rendererStabilityResetTimer = setTimeout(() => {
@@ -771,14 +762,11 @@ function createWindow(): void {
buttons: ['OK'],
}).then(() => {
if (mainWindow !== createdWindow) return;
- windowRecoveryInProgress = true;
- try {
+ failedWindowRecovery.suppressAllClosedQuit(() => {
createdWindow.destroy();
if (mainWindow === createdWindow) mainWindow = null;
void handleMainWindowActivation();
- } finally {
- windowRecoveryInProgress = false;
- }
+ });
}).catch((error) => {
log.error('[Window] Renderer crash recovery failed:', error);
if (!isQuitting && !isShutdownRequested()) {
@@ -1428,7 +1416,7 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => {
- if (process.platform !== 'darwin' && !windowRecoveryInProgress) {
+ if (shouldQuitOnAllWindowsClosed(process.platform, failedWindowRecovery.isReplacingWindow())) {
app.quit();
}
});
diff --git a/main/printers/document-delivery-slip.ts b/main/printers/document-delivery-slip.ts
index f6b8dbf57..f00e72b10 100644
--- a/main/printers/document-delivery-slip.ts
+++ b/main/printers/document-delivery-slip.ts
@@ -83,6 +83,7 @@ export interface DeliverySlipOrderRow {
readonly delivery_note?: unknown;
/** Method expected at handover; NULL is unknown. Never a captured payment. */
readonly expected_payment_method?: unknown;
+ readonly expected_payment_method_id?: unknown;
readonly total?: unknown;
readonly bill?: {
readonly payment_status?: unknown;
@@ -127,6 +128,10 @@ export function buildDeliverySlipPrintData(
String(order?.delivery_note ?? '').trim(),
);
const expectedMethod = sanitizeDeliverySlipPaymentMethod(order?.expected_payment_method);
+ // The persisted identity, not today's method table, decides whether the stored
+ // name prints literally - a later rename or deactivation cannot change it.
+ const expectedMethodIsCustom = Number.isSafeInteger(Number(order?.expected_payment_method_id))
+ && Number(order?.expected_payment_method_id) > 0;
const ticketItems = Array.isArray(items) ? items : [];
const paymentBills = Array.isArray(order?.bills) ? order.bills : order?.bill;
@@ -145,6 +150,7 @@ export function buildDeliverySlipPrintData(
formattedAmount: formatAmount(paymentSummary.amount),
formattedAmountDue: formatAmount(paymentSummary.amountDue),
...(expectedMethod ? { expectedMethod } : {}),
+ ...(expectedMethodIsCustom ? { expectedMethodIsCustom: true } : {}),
}
: undefined;
return {
diff --git a/main/routes/held-orders.ts b/main/routes/held-orders.ts
index 40dccd2c8..e382faf9d 100644
--- a/main/routes/held-orders.ts
+++ b/main/routes/held-orders.ts
@@ -4,6 +4,7 @@ import { getDatabase, now, withTxn } from '../db';
import { requirePermission } from '../services/authorization';
import { randomUUID } from 'crypto';
import { validateItemNotes, validateOrderNotes, validateProductQuantity } from './orders-validation';
+import { CHARGE_ID_PATTERN, MAX_CHARGE_DEFINITIONS, MAX_CHARGE_ID_LENGTH } from '../../shared/charges';
const router = Router();
const heldOrderReadRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false });
@@ -19,6 +20,8 @@ interface HeldOrderRow {
customer_id: string | null;
guest_count: number;
order_notes: string | null;
+ waived_charge_ids: unknown;
+ opted_in_charge_ids: unknown;
created_at: string;
updated_at: string;
}
@@ -64,12 +67,45 @@ function validateHeldOrderItem(item: unknown, db: any): void {
validateItemNotes(db, item.special_instructions);
}
+/**
+ * Charge choices are identifiers only: the engine stays authoritative about
+ * which of them apply, so an id whose definition disappears while a cart sits
+ * held is still stored rather than silently dropped.
+ */
+function sanitizeChargeSelection(value: unknown, field: string): string[] {
+ if (value === undefined || value === null) return [];
+ if (!Array.isArray(value)) throw new Error(`${field} must be an array of charge ids`);
+ if (value.length > MAX_CHARGE_DEFINITIONS) {
+ throw new Error(`${field} must contain at most ${MAX_CHARGE_DEFINITIONS} charge ids`);
+ }
+ const ids: string[] = [];
+ for (const candidate of value) {
+ if (typeof candidate !== 'string' || candidate.length === 0 || candidate.length > MAX_CHARGE_ID_LENGTH
+ || !CHARGE_ID_PATTERN.test(candidate)) {
+ throw new Error(`${field} must contain valid charge ids`);
+ }
+ if (!ids.includes(candidate)) ids.push(candidate);
+ }
+ return ids;
+}
+
+/** A selection stored by an older or damaged build degrades to "no choices". */
+function parseStoredChargeSelection(raw: unknown): string[] {
+ try {
+ return sanitizeChargeSelection(typeof raw === 'string' ? JSON.parse(raw) : raw, 'stored charge selection');
+ } catch {
+ return [];
+ }
+}
+
function validateHeldOrderInput(body: any, db: any): {
tableId: string;
items: unknown[];
customerId: string | number | null;
guestCount: number;
orderNotes: string;
+ waivedChargeIds: string[];
+ optedInChargeIds: string[];
} {
if (!isRecord(body)) {
throw new Error('Request body must be an object');
@@ -98,6 +134,8 @@ function validateHeldOrderInput(body: any, db: any): {
customerId: customerId ?? null,
guestCount: guestCount ?? 1,
orderNotes: orderNotes ?? '',
+ waivedChargeIds: sanitizeChargeSelection(body.waivedChargeIds, 'waivedChargeIds'),
+ optedInChargeIds: sanitizeChargeSelection(body.optedInChargeIds, 'optedInChargeIds'),
};
}
@@ -114,6 +152,8 @@ function parseStoredHeldOrder(row: HeldOrderRow): Record | null
customerId: row.customer_id,
guestCount: Number.isSafeInteger(row.guest_count) && row.guest_count > 0 ? row.guest_count : 1,
orderNotes: row.order_notes || '',
+ waivedChargeIds: parseStoredChargeSelection(row.waived_charge_ids),
+ optedInChargeIds: parseStoredChargeSelection(row.opted_in_charge_ids),
heldAt: row.created_at,
};
} catch {
@@ -151,7 +191,9 @@ router.post('/', heldOrderWriteRateLimit, requirePermission('held-orders.manage'
} catch (error: any) {
return res.status(400).json({ error: error.message });
}
- const { tableId, items, customerId, guestCount, orderNotes } = input;
+ const { tableId, items, customerId, guestCount, orderNotes, waivedChargeIds, optedInChargeIds } = input;
+ const serializedWaivedChargeIds = JSON.stringify(waivedChargeIds);
+ const serializedOptedInChargeIds = JSON.stringify(optedInChargeIds);
let heldOrderId = '';
withTxn(() => {
@@ -161,14 +203,16 @@ router.post('/', heldOrderWriteRateLimit, requirePermission('held-orders.manage'
if (existing) {
db.prepare(`
UPDATE held_orders
- SET id = ?, items = ?, customer_id = ?, guest_count = ?, order_notes = ?, updated_at = ?
+ SET id = ?, items = ?, customer_id = ?, guest_count = ?, order_notes = ?,
+ waived_charge_ids = ?, opted_in_charge_ids = ?, updated_at = ?
WHERE id = ?
- `).run(heldOrderId, JSON.stringify(items), customerId || null, guestCount || 1, orderNotes || '', now(), existing.id);
+ `).run(heldOrderId, JSON.stringify(items), customerId || null, guestCount || 1, orderNotes || '', serializedWaivedChargeIds, serializedOptedInChargeIds, now(), existing.id);
} else {
db.prepare(`
- INSERT INTO held_orders (id, table_id, items, customer_id, guest_count, order_notes, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `).run(heldOrderId, tableId, JSON.stringify(items), customerId || null, guestCount || 1, orderNotes || '', now(), now());
+ INSERT INTO held_orders (id, table_id, items, customer_id, guest_count, order_notes,
+ waived_charge_ids, opted_in_charge_ids, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(heldOrderId, tableId, JSON.stringify(items), customerId || null, guestCount || 1, orderNotes || '', serializedWaivedChargeIds, serializedOptedInChargeIds, now(), now());
}
db.prepare('UPDATE tables SET status = ?, updated_at = ? WHERE id = ?').run(TABLE_STATUS_HELD, now(), tableId);
diff --git a/main/routes/orders-validation.ts b/main/routes/orders-validation.ts
index b9514bf59..aa1f2d136 100644
--- a/main/routes/orders-validation.ts
+++ b/main/routes/orders-validation.ts
@@ -62,6 +62,34 @@ export function resolveExpectedPaymentMethod(db: SettingsLookup, value: unknown)
return custom.name;
}
+/**
+ * Resolves the explicit custom-method identity a caller sends alongside the name
+ * snapshot. The ID is the durable marker, so it is resolved against the active
+ * configuration here and the canonical name is stored with it; the optional name
+ * is only checked for agreement and never invented. Null keeps the legacy
+ * string/sentinel contract untouched.
+ */
+export function resolveExpectedPaymentMethodIdentity(
+ db: SettingsLookup,
+ methodId: unknown,
+ methodName: unknown,
+): { id: number; name: string } | null {
+ if (methodId === undefined || methodId === null) return null;
+ if (typeof methodId !== 'number' || !Number.isSafeInteger(methodId) || methodId <= 0) {
+ throw new Error('expected_payment_method_id must be a positive integer');
+ }
+ const method = db.prepare('SELECT id, name FROM payment_methods WHERE id = ? AND is_active = 1')
+ .get(methodId) as { id: number; name: string } | undefined;
+ if (!method) {
+ throw new Error('expected_payment_method_id must reference an active payment method');
+ }
+ const suppliedName = typeof methodName === 'string' ? methodName.trim() : '';
+ if (suppliedName && suppliedName.toLowerCase() !== method.name.toLowerCase()) {
+ throw new Error('expected_payment_method does not match expected_payment_method_id');
+ }
+ return { id: method.id, name: method.name };
+}
+
export function validateProductQuantity(
product: { name?: string; sale_unit?: string; allow_fractional_quantity?: boolean | number; weight_precision?: number },
quantity: unknown,
diff --git a/main/routes/orders.ts b/main/routes/orders.ts
index 998959b57..14e7211f4 100644
--- a/main/routes/orders.ts
+++ b/main/routes/orders.ts
@@ -20,7 +20,7 @@ import { resolveOrderItemVariant, variantUnitPrice, type ProductVariant } from '
import { applyRecipeSnapshot, buildRecipeSnapshot, parseRecipeSnapshot } from '../services/recipes';
import { notifyKdsUpdate, notifyOrderUpdated } from '../services/kds';
import { cloudSync } from '../services/cloud-sync';
-import { validateOrderNotes, validateItemNotes, validateProductQuantity, validateDeliveryAddress, validateDeliveryNote, resolveExpectedPaymentMethod } from './orders-validation';
+import { validateOrderNotes, validateItemNotes, validateProductQuantity, validateDeliveryAddress, validateDeliveryNote, resolveExpectedPaymentMethod, resolveExpectedPaymentMethodIdentity } from './orders-validation';
import { hasPermission, requirePermission } from '../services/authorization';
import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions';
import { getCurrencyFractionDigits, getCurrencyMinorUnitFactor } from '../countries';
@@ -627,7 +627,7 @@ router.get('/:id', orderReadRateLimit, requirePermission('orders.read'), (req: R
router.post('/', orderWriteRateLimit, requirePermission('orders.create'), (req: Request, res: Response) => {
try {
const body = req.body || {};
- const { table_id, customer_id, type, guest_count, special_instructions, packaging_charge, delivery_charge, service_charge, items, online_platform, external_order_id, delivery_address, expected_payment_method, delivery_note, waived_charge_ids, opted_in_charge_ids } = body;
+ const { table_id, customer_id, type, guest_count, special_instructions, packaging_charge, delivery_charge, service_charge, items, online_platform, external_order_id, delivery_address, expected_payment_method, expected_payment_method_id, delivery_note, waived_charge_ids, opted_in_charge_ids } = body;
// Carries optional service charge without automatic calculation policy.
const idempotencyKey = orderIdempotencyKey(req);
const idempotencyUserId = String((req as any).user.userId);
@@ -709,10 +709,25 @@ router.post('/', orderWriteRateLimit, requirePermission('orders.create'), (req:
// Free text that ends up printed on a courier slip, so it is capped and
// validated the same way order notes are, at this boundary.
let expectedPaymentMethod: string | null;
+ let expectedPaymentMethodId: number | null = null;
try {
validateDeliveryAddress(db, deliveryAddress);
validateDeliveryNote(db, deliveryNote);
- expectedPaymentMethod = isDelivery ? resolveExpectedPaymentMethod(db, expected_payment_method) : null;
+ if (isDelivery) {
+ // An explicit configured-method identity wins over the string contract:
+ // a method literally named "Pending" or "Unknown" would otherwise be
+ // read back as the sentinel. Its canonical name is stored with the ID.
+ const identity = resolveExpectedPaymentMethodIdentity(db, expected_payment_method_id, expected_payment_method);
+ if (identity) {
+ expectedPaymentMethod = identity.name;
+ expectedPaymentMethodId = identity.id;
+ } else {
+ expectedPaymentMethod = resolveExpectedPaymentMethod(db, expected_payment_method);
+ }
+ } else {
+ // Non-delivery orders ignore delivery collection metadata, like before.
+ expectedPaymentMethod = null;
+ }
} catch (err: unknown) {
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid delivery details' });
}
@@ -779,12 +794,12 @@ router.post('/', orderWriteRateLimit, requirePermission('orders.create'), (req:
const orderCustomerId = customer_id || reservedCustomerId || null;
const orderResult = db.prepare(`
- INSERT INTO orders (order_number, table_id, customer_id, user_id, type, delivery_address, expected_payment_method, delivery_note, guest_count, special_instructions,
+ INSERT INTO orders (order_number, table_id, customer_id, user_id, type, delivery_address, expected_payment_method, expected_payment_method_id, delivery_note, guest_count, special_instructions,
packaging_charge, delivery_charge, packaging_tax_category_id, delivery_tax_category_id,
service_charge, service_charge_tax_category_id, online_platform, external_order_id, status, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`).run(orderNumber, table_id || null, orderCustomerId, authenticatedUserId, type, deliveryAddress,
- expectedPaymentMethod, deliveryNote, guest_count || null, special_instructions || null, pkgCharge, delCharge,
+ expectedPaymentMethod, expectedPaymentMethodId, deliveryNote, guest_count || null, special_instructions || null, pkgCharge, delCharge,
chargeContext.packaging_tax_category_id, chargeContext.delivery_tax_category_id,
serviceCharge, chargeContext.service_charge_tax_category_id,
onlinePlatform || null, externalOrderId || null, now(), now());
diff --git a/main/routes/printers.ts b/main/routes/printers.ts
index 48659782e..7ebe75fc1 100644
--- a/main/routes/printers.ts
+++ b/main/routes/printers.ts
@@ -406,21 +406,42 @@ router.post('/print-menu', requirePermission('catalog.view'), requirePermission(
stock_quantity: number | null;
sort_order: number | null;
}>;
- const relations = body.includeModifiers ? loadProductRelationsBatch(db, productRows) : new Map();
- const products = productRows.map((product) => ({
- description: product.description,
- modifiers: (relations.get(product.id)?.addon_groups || []).map((group: { name: string; addons: { name: string; price: number }[] }) => ({
- name: group.name,
- options: group.addons.map((addon) => ({ name: addon.name, price: addon.price })),
- })),
- categoryId: typeof product.category_id === 'string' ? product.category_id : null,
- name: String(product.name ?? ''),
- price: Number(product.price),
- isActive: product.is_active === 1,
- trackInventory: product.track_inventory === 1,
- stockQuantity: Number(product.stock_quantity) || 0,
- sortOrder: Number(product.sort_order) || 0,
- }));
+ // Sale rows need active variants even when add-on modifiers are off, so the
+ // relation loader always runs.
+ const relations = loadProductRelationsBatch(db, productRows);
+ const products = productRows.flatMap((product) => {
+ const base = {
+ description: product.description,
+ modifiers: (relations.get(product.id)?.addon_groups || []).map((group: { name: string; addons: { name: string; price: number }[] }) => ({
+ name: group.name,
+ options: group.addons.map((addon) => ({ name: addon.name, price: addon.price })),
+ })),
+ categoryId: typeof product.category_id === 'string' ? product.category_id : null,
+ name: String(product.name ?? ''),
+ price: Number(product.price),
+ isActive: product.is_active === 1,
+ trackInventory: product.track_inventory === 1,
+ stockQuantity: Number(product.stock_quantity) || 0,
+ sortOrder: Number(product.sort_order) || 0,
+ };
+ // The relation loader returns only active variants, so an empty list means
+ // the parent row is the only sellable form the backend offers.
+ const variants = (relations.get(product.id)?.variants || []) as Array>;
+ if (variants.length === 0) return [base];
+ return variants.map((variant, variantIndex) => ({
+ ...base,
+ name: `${base.name} (${String(variant.name ?? '')})`,
+ // The loader returns variants in catalog order; the minor key keeps that
+ // order inside the parent's slot in the document's flat, sorted row list.
+ sortOrder: base.sortOrder + (variantIndex + 1) / (variants.length + 1),
+ price: Number(variant.price),
+ isActive: base.isActive && variant.is_active === 1,
+ // Mirrors POS gating: a variant that links to a recipe ingredient never
+ // sells from its own pool, so that pool cannot advertise it as sold out.
+ trackInventory: variant.inventory_product_id ? false : variant.track_inventory === 1,
+ stockQuantity: Number(variant.stock_quantity) || 0,
+ }));
+ });
const settings = Object.fromEntries(
(db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[])
.map((row) => [row.key, row.value]),
diff --git a/main/window-recovery.ts b/main/window-recovery.ts
new file mode 100644
index 000000000..10c12ea4d
--- /dev/null
+++ b/main/window-recovery.ts
@@ -0,0 +1,84 @@
+/**
+ * Main-window failed-load recovery.
+ *
+ * Recovery destroys the failed window before its replacement exists - the only
+ * way to reuse Electron's persisted window name. Destroying the last window
+ * raises `window-all-closed`, so the replacement flag has to be set before the
+ * destroy or Windows/Linux quit the app mid-recovery.
+ */
+
+export interface RecoverableWindow {
+ isDestroyed(): boolean;
+ destroy(): void;
+}
+
+export interface FailedWindowRecoveryOptions {
+ getMainWindow: () => RecoverableWindow | null;
+ /** True when a quit or shutdown already owns the lifecycle. */
+ isAborted: () => boolean;
+ /** False when runtime services are down, so relaunching is the only recovery. */
+ isRuntimeHealthy: () => boolean;
+ requestRelaunch: (reason: string) => void;
+ createWindow: () => void;
+ logError?: (message: string, error: unknown) => void;
+}
+
+export interface FailedWindowRecovery {
+ /** True while a failed window is being replaced. */
+ isReplacingWindow(): boolean;
+ /** Runs `work` with the all-closed quit suppressed, restoring the prior state. */
+ suppressAllClosedQuit(work: () => T): T;
+ /** Re-arms in-place recovery after a window loaded successfully. */
+ markLoadSucceeded(): void;
+ recover(failedWindow: RecoverableWindow): void;
+}
+
+/** Windows/Linux quit on the last closed window; macOS keeps the app alive. */
+export function shouldQuitOnAllWindowsClosed(platform: string, replacingWindow: boolean): boolean {
+ return platform !== 'darwin' && !replacingWindow;
+}
+
+export function createFailedWindowRecovery(options: FailedWindowRecoveryOptions): FailedWindowRecovery {
+ let recoveryAttempted = false;
+ let replacingWindow = false;
+
+ const suppressAllClosedQuit = (work: () => T): T => {
+ const previous = replacingWindow;
+ replacingWindow = true;
+ try {
+ return work();
+ } finally {
+ replacingWindow = previous;
+ }
+ };
+
+ const recover = (failedWindow: RecoverableWindow): void => {
+ if (options.isAborted()) return;
+ if (options.getMainWindow() !== failedWindow) return;
+ if (!options.isRuntimeHealthy()) {
+ options.requestRelaunch('window-load-retry-exhausted');
+ return;
+ }
+ if (recoveryAttempted) {
+ options.requestRelaunch('window-load-recovery-failed');
+ return;
+ }
+ recoveryAttempted = true;
+ suppressAllClosedQuit(() => {
+ try {
+ if (!failedWindow.isDestroyed()) failedWindow.destroy();
+ options.createWindow();
+ } catch (error) {
+ options.logError?.('[Window] Window recreation failed:', error);
+ options.requestRelaunch('window-load-recovery-create-failed');
+ }
+ });
+ };
+
+ return {
+ isReplacingWindow: () => replacingWindow,
+ suppressAllClosedQuit,
+ markLoadSucceeded: () => { recoveryAttempted = false; },
+ recover,
+ };
+}
diff --git a/shared/print/document.ts b/shared/print/document.ts
index 93ad1903e..b6c07f325 100644
--- a/shared/print/document.ts
+++ b/shared/print/document.ts
@@ -1048,8 +1048,15 @@ export function sanitizeDeliverySlipPaymentMethod(value: unknown): string {
export function deliverySlipExpectedPaymentText(
expectedMethod: unknown,
resolveLabel: (conceptId: LabelConceptId) => string,
+ expectedMethodIsCustom = false,
): string {
const method = sanitizeDeliverySlipPaymentMethod(expectedMethod);
+ // A configured method named "Pending" or "Unknown" was recorded as its own
+ // identity, so its historical name prints literally instead of collapsing
+ // into the localized sentinel for that word.
+ if (expectedMethodIsCustom) {
+ return `${resolveLabel('print.deliverySlip.expectedPayment')}: ${method || resolveLabel('common.unknown')}`;
+ }
const normalized = method.toLowerCase();
if (normalized === 'cash') return resolveLabel('print.deliverySlip.cashOnDelivery');
const methodConcept = PAYMENT_METHOD_CONCEPTS[normalized];
@@ -1217,6 +1224,8 @@ export interface DeliverySlipPrintData {
readonly formattedAmountDue?: string;
/** Method expected at handover, separate from any captured payment; absent is unknown. */
readonly expectedMethod?: string;
+ /** True when `expectedMethod` is a configured method's stored name, so it prints literally. */
+ readonly expectedMethodIsCustom?: boolean;
};
readonly items: readonly {
readonly productName: string;
@@ -1667,7 +1676,11 @@ export function buildDeliverySlipDocument(
: paymentData.status === 'unpaid' && paymentData.amount > 0
? {
detailsText: directionalText(
- deliverySlipExpectedPaymentText(paymentData.expectedMethod, (conceptId) => resolveSemanticLabel(labels, conceptId).primary),
+ deliverySlipExpectedPaymentText(
+ paymentData.expectedMethod,
+ (conceptId) => resolveSemanticLabel(labels, conceptId).primary,
+ paymentData.expectedMethodIsCustom === true,
+ ),
base,
),
}
diff --git a/tests/cart-variant-identity.test.ts b/tests/cart-variant-identity.test.ts
index 6a040f92c..def23d822 100644
--- a/tests/cart-variant-identity.test.ts
+++ b/tests/cart-variant-identity.test.ts
@@ -240,4 +240,43 @@ assert.equal(scanned.length, 1, 'a scanned variant barcode lands a single line i
assert.equal(scanned[0].variant?.id, 'var-scan-m', 'the scanned line carries the scanned variant');
assert.equal(scanned[0].quantity, 1, 'the scanned line is added at quantity 1');
+// A resumed held cart restores the cashier's charge decisions as live Sets.
+const restoredWaived = ['service_charge', 'late_fee'];
+const restoredOptedIn = ['optional_packing'];
+cart().clearCart();
+cart().loadItems([], 'tbl-resume', null, 2, 'no sugar', 'ho-resume', restoredWaived, restoredOptedIn);
+assert.deepEqual([...cart().waivedChargeIds], ['service_charge', 'late_fee'], 'a resumed cart restores the waived charges');
+assert.deepEqual([...cart().optedInChargeIds], ['optional_packing'], 'a resumed cart restores the opted-in charges');
+assert.ok(cart().waivedChargeIds instanceof Set, 'restored waivers are live Sets, not arrays');
+assert.equal(cart().orderType, 'dine_in', 'restoring a cart keeps the current order type');
+
+// The Sets are independent of the arrays they were built from.
+restoredWaived.push('courier_fee');
+restoredOptedIn.length = 0;
+assert.equal(cart().waivedChargeIds.has('courier_fee'), false, 'mutating the source array cannot alter the restored waivers');
+assert.equal(cart().optedInChargeIds.size, 1, 'mutating the source array cannot alter the restored opt-ins');
+cart().toggleWaiveCharge('late_fee');
+assert.equal(cart().waivedChargeIds.has('late_fee'), false, 'restored waivers stay toggleable');
+
+// Legacy and caller-less restores default to no decisions.
+cart().clearCart();
+cart().loadItems([], 'tbl-legacy', null, 2, '', 'ho-legacy');
+assert.equal(cart().waivedChargeIds.size, 0, 'a legacy held order restores with no waivers');
+assert.equal(cart().optedInChargeIds.size, 0, 'a legacy held order restores with no opt-ins');
+cart().toggleWaiveCharge('service_charge');
+cart().toggleOptedInCharge('optional_packing');
+cart().loadItems([], 'tbl-next', null, 1, '', 'ho-next', ['gift_wrap'], []);
+assert.deepEqual([...cart().waivedChargeIds], ['gift_wrap'], 'the next restore replaces the previous waivers');
+assert.equal(cart().optedInChargeIds.size, 0, 'the next restore replaces the previous opt-ins');
+
+// Leaving the cart clears the decisions with everything else.
+cart().clearCart();
+assert.equal(cart().waivedChargeIds.size, 0, 'clearCart clears waived charges');
+assert.equal(cart().optedInChargeIds.size, 0, 'clearCart clears opted-in charges');
+cart().loadItems([], 'tbl-reset', null, 1, '', 'ho-reset', ['service_charge'], ['optional_packing']);
+cart().setOrderType('delivery');
+assert.equal(cart().waivedChargeIds.size, 0, 'a real order-type change resets waived charges');
+assert.equal(cart().optedInChargeIds.size, 0, 'a real order-type change resets opted-in charges');
+cart().clearCart();
+
console.log('✓ cart variant identity and cart store checks passed');
diff --git a/tests/delivery-address-egress.test.ts b/tests/delivery-address-egress.test.ts
index e507b68a5..2b6d615d3 100644
--- a/tests/delivery-address-egress.test.ts
+++ b/tests/delivery-address-egress.test.ts
@@ -224,6 +224,161 @@ test('delivery details: the expected method and courier note persist without rec
}
});
+test('expected-method identity: a configured method is resolved by its ID, never by its name', () => {
+ const db = initTestDb();
+ const { resolveExpectedPaymentMethod, resolveExpectedPaymentMethodIdentity } = require('../main/routes/orders-validation');
+ try {
+ db.prepare("INSERT OR IGNORE INTO payment_methods (id, name, is_active, sort_order) VALUES (71, 'Pending', 1, 10)").run();
+ db.prepare("INSERT OR IGNORE INTO payment_methods (id, name, is_active, sort_order) VALUES (72, 'Unknown', 1, 11)").run();
+ db.prepare("INSERT OR IGNORE INTO payment_methods (id, name, is_active, sort_order) VALUES (73, 'Voucher', 0, 12)").run();
+
+ // The legacy string contract is untouched: a bare name still resolves through
+ // the sentinel path, which is exactly why the ID has to travel separately.
+ assert.equal(resolveExpectedPaymentMethod(db, 'Pending'), 'pending',
+ 'a bare name that collides with a sentinel keeps its legacy sentinel meaning');
+ assert.equal(resolveExpectedPaymentMethod(db, 'Unknown'), null,
+ 'a bare `Unknown` keeps its legacy unknown meaning');
+
+ assert.deepEqual(resolveExpectedPaymentMethodIdentity(db, 71, 'Pending'),
+ { id: 71, name: 'Pending' }, 'an active custom method resolves by its ID');
+ assert.deepEqual(resolveExpectedPaymentMethodIdentity(db, 72, undefined),
+ { id: 72, name: 'Unknown' }, 'an omitted name is not a mismatch');
+ assert.deepEqual(resolveExpectedPaymentMethodIdentity(db, 71, ' pEnDiNg '),
+ { id: 71, name: 'Pending' }, 'the name check is trimmed and case-insensitive');
+ assert.equal(resolveExpectedPaymentMethodIdentity(db, undefined, 'Voucher'), null,
+ 'no ID keeps the caller on the legacy string contract');
+ assert.equal(resolveExpectedPaymentMethodIdentity(db, null, null), null, 'a null ID is the legacy contract');
+
+ for (const [id, name, why] of [
+ [0, undefined, 'zero is not a method identity'],
+ [-3, undefined, 'a negative ID is not a method identity'],
+ [1.5, undefined, 'a fractional ID is not a method identity'],
+ ['71', undefined, 'a string ID is not accepted in place of the numeric contract'],
+ [9999, undefined, 'an unknown ID is refused'],
+ [73, undefined, 'an inactive method cannot be a new expectation'],
+ [71, 'Cash', 'a mismatched name is refused rather than silently substituted'],
+ ] as const) {
+ assert.throws(
+ () => resolveExpectedPaymentMethodIdentity(db, id, name),
+ why,
+ );
+ }
+ } finally {
+ closeDatabase();
+ }
+});
+
+test('delivery details: a custom method identity survives names that collide with sentinels', async () => {
+ const db = initTestDb();
+ const owner = seedOwnerUser(db);
+ seedCategory(db, 'cat-1', 'Coffee');
+ seedProduct(db, 'product-1', 'cat-1', 'Espresso', 250);
+ db.prepare("INSERT OR IGNORE INTO payment_methods (name, is_active, sort_order) VALUES ('Pending', 1, 10)").run();
+ db.prepare("INSERT OR IGNORE INTO payment_methods (name, is_active, sort_order) VALUES ('Unknown', 1, 11)").run();
+ const idOf = (name: string) => (db.prepare('SELECT id FROM payment_methods WHERE name = ?').get(name) as { id: number }).id;
+ const pendingId = idOf('Pending');
+ const unknownId = idOf('Unknown');
+ assert.notEqual(pendingId, unknownId, 'the two custom methods are distinct rows');
+ const { baseUrl, server } = await startServer(testApp());
+ const createDelivery = (details: Record, key?: string) => api(baseUrl, '/api/orders', {
+ method: 'POST',
+ headers: key ? { ...owner.authHeader, 'Idempotency-Key': key } : owner.authHeader,
+ body: { type: 'delivery', items: [{ product_id: 'product-1', quantity: 1 }], ...details },
+ });
+ const stored = (id: number) => db.prepare(
+ 'SELECT expected_payment_method, expected_payment_method_id FROM orders WHERE id = ?',
+ ).get(id);
+ try {
+ const pending = await createDelivery({ expected_payment_method_id: pendingId, expected_payment_method: 'Pending' });
+ assert.equal(pending.status, 201, 'a configured method named Pending is accepted by ID');
+ assert.deepEqual(
+ { ...stored(pending.data.order.id) },
+ { expected_payment_method: 'Pending', expected_payment_method_id: pendingId },
+ 'the stored name stays literal and the identity is the ID',
+ );
+ assert.equal(
+ pending.data.order.expected_payment_method_id,
+ pendingId,
+ 'the created order response carries the identity',
+ );
+
+ const unknown = await createDelivery({ expected_payment_method_id: unknownId, expected_payment_method: 'Unknown' });
+ assert.equal(unknown.status, 201, 'a configured method named Unknown is accepted by ID');
+ assert.deepEqual(
+ { ...stored(unknown.data.order.id) },
+ { expected_payment_method: 'Unknown', expected_payment_method_id: unknownId },
+ 'an Unknown-named custom method is stored as Unknown plus its ID, never as null',
+ );
+
+ // Old clients keep the exact legacy contract, including null identities.
+ const legacy = await createDelivery({ expected_payment_method: 'pending' });
+ assert.deepEqual(
+ { ...stored(legacy.data.order.id) },
+ { expected_payment_method: 'pending', expected_payment_method_id: null },
+ 'a legacy sentinel request stores no identity',
+ );
+ const builtin = await createDelivery({ expected_payment_method: 'card' });
+ assert.deepEqual(
+ { ...stored(builtin.data.order.id) },
+ { expected_payment_method: 'card', expected_payment_method_id: null },
+ 'a built-in method stores no identity',
+ );
+
+ // Non-delivery orders ignore both expected-method fields.
+ const takeaway = await api(baseUrl, '/api/orders', {
+ method: 'POST',
+ headers: owner.authHeader,
+ body: {
+ type: 'takeaway',
+ items: [{ product_id: 'product-1', quantity: 1 }],
+ expected_payment_method_id: pendingId,
+ expected_payment_method: 'Pending',
+ },
+ });
+ assert.equal(takeaway.status, 201, 'a non-delivery order is still accepted');
+ assert.deepEqual(
+ { ...stored(takeaway.data.order.id) },
+ { expected_payment_method: null, expected_payment_method_id: null },
+ 'a non-delivery order persists no expected-method identity',
+ );
+
+ for (const details of [
+ { expected_payment_method_id: 9999 },
+ { expected_payment_method_id: 0 },
+ { expected_payment_method_id: String(pendingId) },
+ { expected_payment_method_id: pendingId, expected_payment_method: 'Card' },
+ ]) {
+ const rejected = await createDelivery(details);
+ assert.equal(rejected.status, 400, `${JSON.stringify(details)} is refused`);
+ }
+
+ // An idempotent replay of the same payload returns the same order, and a
+ // different identity under that key is not silently substituted.
+ const firstKey = 'identity-replay-key';
+ const firstReplay = await createDelivery(
+ { expected_payment_method_id: pendingId, expected_payment_method: 'Pending' }, firstKey,
+ );
+ const secondReplay = await createDelivery(
+ { expected_payment_method_id: pendingId, expected_payment_method: 'Pending' }, firstKey,
+ );
+ assert.equal(secondReplay.status, 200, 'the replay is served from the stored response');
+ assert.equal(secondReplay.data.order.id, firstReplay.data.order.id, 'the replay returns the original order');
+ assert.equal(secondReplay.data.order.expected_payment_method_id, pendingId, 'the replay keeps the identity');
+ const mismatchedReplay = await createDelivery(
+ { expected_payment_method_id: unknownId, expected_payment_method: 'Unknown' }, firstKey,
+ );
+ assert.equal(mismatchedReplay.status, 409,
+ 'a different identity under the same idempotency key is refused, not swapped in');
+ assert.equal(
+ db.prepare('SELECT COUNT(*) AS c FROM orders WHERE expected_payment_method_id = ?').get(unknownId).c, 1,
+ 'the refused replay does not create a second order',
+ );
+ } finally {
+ server.close();
+ closeDatabase();
+ }
+});
+
test('delivery details: an uncollectable method or an over-long note is refused at the boundary', async () => {
const db = initTestDb();
const owner = seedOwnerUser(db);
diff --git a/tests/delivery-slip-printing.test.ts b/tests/delivery-slip-printing.test.ts
index 8d2efbe09..267bb7a87 100644
--- a/tests/delivery-slip-printing.test.ts
+++ b/tests/delivery-slip-printing.test.ts
@@ -7,7 +7,7 @@ import * as path from 'node:path';
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { buildDeliverySlipDocument, isDeliverySlipDocument, shouldShowCustomerNumber } from '../shared/print/document';
+import { buildDeliverySlipDocument, deliverySlipExpectedPaymentText, isDeliverySlipDocument, shouldShowCustomerNumber } from '../shared/print/document';
import { buildDeliverySlipPrintData, renderDeliverySlipViaDocument, MAX_DELIVERY_SLIP_ADDRESS_CHARS, MAX_DELIVERY_SLIP_NOTE_CHARS } from '../main/printers/document-delivery-slip';
import { clampDeliverySlipText } from '../shared/print/document';
import { formatKOT, formatReceipt, escPosToText } from '../main/printers/thermal';
@@ -1754,6 +1754,138 @@ test('delivery slip: an unpaid balance states the expected method, and unknown o
);
});
+test('delivery slip: a configured method prints its stored name literally while sentinels stay localized', () => {
+ // A non-English resolver shows the difference: a built-in sentinel is
+ // translated, a configured method's historical name is not.
+ const labels: Record = {
+ 'print.deliverySlip.expectedPayment': 'Zahlung erwartet',
+ 'print.deliverySlip.cashOnDelivery': 'Barzahlung bei Lieferung',
+ 'orders.pending': 'Ausstehend',
+ 'common.unknown': 'Unbekannt',
+ 'pos.methodCard': 'Karte',
+ };
+ const resolveLabel = (conceptId: string) => labels[conceptId] ?? conceptId;
+
+ assert.equal(
+ deliverySlipExpectedPaymentText('pending', resolveLabel),
+ 'Zahlung erwartet: Ausstehend',
+ 'a built-in pending sentinel keeps its localized meaning',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText('Pending', resolveLabel, true),
+ 'Zahlung erwartet: Pending',
+ 'a configured method named Pending prints its stored name literally',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText('Card', resolveLabel),
+ 'Zahlung erwartet: Karte',
+ 'a built-in name without the marker still resolves through its concept label',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText('Card', resolveLabel, true),
+ 'Zahlung erwartet: Card',
+ 'the marker keeps a configured method\'s own name instead of the built-in label',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText('Unknown', resolveLabel, true),
+ 'Zahlung erwartet: Unknown',
+ 'a configured method named Unknown prints its literal name',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText('Cash', resolveLabel, true),
+ 'Zahlung erwartet: Cash',
+ 'a configured method whose name matches a built-in word is not translated',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText('{CUT}\nCash', resolveLabel, true),
+ 'Zahlung erwartet: CUT Cash',
+ 'a custom name is still stripped of printer tokens and line breaks before printing',
+ );
+ assert.equal(
+ deliverySlipExpectedPaymentText(undefined, resolveLabel, true),
+ 'Zahlung erwartet: Unbekannt',
+ 'an absent name with the marker still reads as unknown',
+ );
+
+ // The marker travels with the print data and reaches every slip renderer.
+ const customOrder = { ...ORDER, total: 25, expected_payment_method: 'Pending', expected_payment_method_id: 17 };
+ const printData = buildDeliverySlipPrintData(customOrder, ORDER.items, CONTACT, { locale: 'en-US', currency: 'USD' });
+ assert.equal(printData.payment?.expectedMethodIsCustom, true, 'the persisted identity sets the print marker');
+ assert.equal(printData.payment?.expectedMethod, 'Pending', 'the stored name travels unchanged');
+ for (const [name, text] of renderAllSlipPaths(customOrder)) {
+ assert.ok(text.includes('Expected payment: Pending'), `${name}: the custom name prints literally, got:\n${text}`);
+ }
+
+ // Renaming or deactivating the configured method cannot rewrite the snapshot:
+ // the marker comes from the order's own identity column.
+ const renamedOrder = { ...customOrder, expected_payment_method: 'Express Cash' };
+ assert.equal(
+ buildDeliverySlipPrintData(renamedOrder, ORDER.items, CONTACT, { locale: 'en-US', currency: 'USD' })
+ .payment?.expectedMethodIsCustom,
+ true,
+ 'the marker follows the order row, not today\'s method table',
+ );
+
+ // A slip built from the snapshot keeps the marker through document construction.
+ const document = buildDeliverySlipDocument(printData, {
+ columns: 42,
+ languages: ['en'],
+ baseDirection: 'ltr',
+ locale: 'en-US',
+ currency: 'USD',
+ currencySymbol: '$',
+ trimDecimals: false,
+ resolveLabel: (conceptId) => conceptId,
+ });
+ const paymentBlock = document.blocks.find((block) => block.kind === 'delivery-slip-payment') as any;
+ assert.equal(
+ paymentBlock?.detailsText?.text,
+ 'print.deliverySlip.expectedPayment: Pending',
+ 'the semantic document prints the stored name literally instead of the sentinel label',
+ );
+ assert.equal(
+ isDeliverySlipDocument(JSON.parse(JSON.stringify(document))),
+ true,
+ 'a serialized custom-method slip still validates',
+ );
+ const sentinelDocument = buildDeliverySlipDocument(
+ buildDeliverySlipPrintData(
+ { ...ORDER, total: 25, expected_payment_method: 'pending' },
+ ORDER.items,
+ CONTACT,
+ { locale: 'en-US', currency: 'USD' },
+ ),
+ {
+ columns: 42,
+ languages: ['en'],
+ baseDirection: 'ltr',
+ locale: 'en-US',
+ currency: 'USD',
+ currencySymbol: '$',
+ trimDecimals: false,
+ resolveLabel: (conceptId) => conceptId,
+ },
+ );
+ assert.equal(
+ (sentinelDocument.blocks.find((block) => block.kind === 'delivery-slip-payment') as any)?.detailsText?.text,
+ 'print.deliverySlip.expectedPayment: orders.pending',
+ 'a legacy sentinel still resolves through its localized label',
+ );
+
+ // Legacy string-only orders keep their prior meaning on every path.
+ const legacyOrder = { ...ORDER, total: 25, expected_payment_method: 'Pending' } as any;
+ for (const [name, text] of renderAllSlipPaths(legacyOrder)) {
+ assert.ok(text.includes('Expected payment: Pending'), `${name}: a legacy order keeps its prior print, got:\n${text}`);
+ }
+ assert.equal(
+ buildDeliverySlipPrintData(legacyOrder, ORDER.items, CONTACT, { locale: 'en-US', currency: 'USD' })
+ .payment?.expectedMethodIsCustom,
+ undefined,
+ 'a legacy order without an identity carries no custom marker',
+ );
+});
+
+
test('delivery slip: a captured payment wins over the expected method', () => {
const paidOrder = {
...ORDER,
diff --git a/tests/held-orders-store.test.ts b/tests/held-orders-store.test.ts
index 5ad8768cc..c2a8342b5 100644
--- a/tests/held-orders-store.test.ts
+++ b/tests/held-orders-store.test.ts
@@ -16,6 +16,8 @@ type HeldOrder = {
customerId: number | string | null;
guestCount: number;
orderNotes: string;
+ waivedChargeIds?: string[];
+ optedInChargeIds?: string[];
heldAt: string;
};
@@ -50,7 +52,15 @@ const serverApi = {
}
return response;
},
- post: async (url: string, body: { tableId: string; items: HeldOrder['items']; customerId: HeldOrder['customerId']; guestCount: number; orderNotes: string }) => {
+ post: async (url: string, body: {
+ tableId: string;
+ items: HeldOrder['items'];
+ customerId: HeldOrder['customerId'];
+ guestCount: number;
+ orderNotes: string;
+ waivedChargeIds?: string[];
+ optedInChargeIds?: string[];
+ }) => {
assert.equal(url, '/held-orders');
const id = `ho-${body.tableId}-${++nextOrderId}`;
serverOrders.set(body.tableId, {
@@ -60,6 +70,8 @@ const serverApi = {
customerId: body.customerId,
guestCount: body.guestCount,
orderNotes: body.orderNotes,
+ waivedChargeIds: clone(body.waivedChargeIds ?? []),
+ optedInChargeIds: clone(body.optedInChargeIds ?? []),
heldAt: '2026-08-01T00:00:00.000Z',
});
return { data: { success: true, id } };
@@ -265,6 +277,77 @@ async function main() {
assert.equal(secondTerminal.getState().hasHeldOrder('table-c'), true, 'delete errors retain the cached order for retry');
assert.equal(serverOrders.has('table-c'), true, 'delete errors do not consume the server row');
+ // ── Charge selections travel with the hold and come back on resume ──────
+ const selectionStore = createHeldOrdersStore(serverApi);
+ const selectionItems = [{
+ id: 'selection-line',
+ product: { id: 'product-selection', name: 'Latte', price: 100 },
+ quantity: 1,
+ addons: [],
+ special_instructions: '',
+ }];
+ await selectionStore.getState().holdOrder(
+ 'table-selection', selectionItems, null, 2, 'no sugar', ['service_charge'], ['optional_packing'],
+ );
+ assert.deepEqual(
+ serverOrders.get('table-selection')?.waivedChargeIds,
+ ['service_charge'],
+ 'the hold request carries the waived charge ids to the server',
+ );
+ assert.deepEqual(
+ serverOrders.get('table-selection')?.optedInChargeIds,
+ ['optional_packing'],
+ 'the hold request carries the opted-in charge ids to the server',
+ );
+ const heldLocally = selectionStore.getState().getHeldOrder('table-selection');
+ assert.deepEqual(heldLocally?.waivedChargeIds, ['service_charge'], 'the local snapshot keeps the waived selection');
+ assert.deepEqual(heldLocally?.optedInChargeIds, ['optional_packing'], 'the local snapshot keeps the opted-in selection');
+
+ const selectionResume = createHeldOrdersStore(serverApi);
+ await selectionResume.getState().fetchHeldOrders();
+ const fetchedSelections = selectionResume.getState().getHeldOrder('table-selection');
+ assert.deepEqual(fetchedSelections?.waivedChargeIds, ['service_charge'], 'a fetched hold exposes the waived selection');
+ assert.deepEqual(fetchedSelections?.optedInChargeIds, ['optional_packing'], 'a fetched hold exposes the opted-in selection');
+ const restoredSelections = await selectionResume.getState().restoreOrder('table-selection');
+ assert.deepEqual(restoredSelections?.waivedChargeIds, ['service_charge'], 'a resumed hold returns the waived selection');
+ assert.deepEqual(restoredSelections?.optedInChargeIds, ['optional_packing'], 'a resumed hold returns the opted-in selection');
+
+ // A hold written by an older build has no selection fields at all.
+ serverOrders.set('table-legacy-selection', makeHeldOrder('table-legacy-selection'));
+ await selectionResume.getState().fetchHeldOrders();
+ const legacySelections = selectionResume.getState().getHeldOrder('table-legacy-selection');
+ assert.deepEqual(legacySelections?.waivedChargeIds, [], 'a legacy hold without selections defaults to empty waivers');
+ assert.deepEqual(legacySelections?.optedInChargeIds, [], 'a legacy hold without selections defaults to empty opt-ins');
+
+ // Overwriting a hold replaces both selections.
+ await selectionStore.getState().holdOrder(
+ 'table-selection', selectionItems, null, 2, 'no sugar', [], ['optional_packing', 'gift_wrap'],
+ );
+ await selectionStore.getState().fetchHeldOrders();
+ const overwritten = selectionStore.getState().getHeldOrder('table-selection');
+ assert.deepEqual(overwritten?.waivedChargeIds, [], 'an overwrite clears the waived selection');
+ assert.deepEqual(overwritten?.optedInChargeIds, ['optional_packing', 'gift_wrap'], 'an overwrite replaces the opted-in selection');
+
+ // A failed hold must not replace the cached hold or its selections.
+ const failedHoldStore = createHeldOrdersStore(serverApi);
+ await failedHoldStore.getState().fetchHeldOrders();
+ const beforeFailedHold = failedHoldStore.getState().getHeldOrder('table-selection');
+ const postFailure = new Error('hold rejected');
+ const originalPost = serverApi.post;
+ serverApi.post = async () => { throw postFailure; };
+ await assert.rejects(
+ () => failedHoldStore.getState().holdOrder('table-selection', selectionItems, null, 2, '', ['late_fee'], ['optional_packing']),
+ /hold rejected/,
+ 'a failed hold surfaces its error',
+ );
+ serverApi.post = originalPost;
+ assert.deepEqual(
+ failedHoldStore.getState().getHeldOrder('table-selection'),
+ beforeFailedHold,
+ 'a failed hold leaves the cached hold and its selections untouched',
+ );
+ console.log(' ✓ held charge selections survive hold, fetch, overwrite, and resume');
+
console.log('\n✅ Held-order client store regression tests passed');
}
diff --git a/tests/held-orders.test.ts b/tests/held-orders.test.ts
index 27f189ca8..9b9a95178 100644
--- a/tests/held-orders.test.ts
+++ b/tests/held-orders.test.ts
@@ -130,6 +130,132 @@ async function main() {
});
assertEqualOrThrow(disallowedFraction.status, 400, 'POST /held-orders rejects fractional quantity for a whole-unit product');
+ // ═══════════════════════════════════════════════════════════════════
+ console.log('\n─── Scenario B3: held carts keep waived/opted-in charge choices ───');
+ const selectionTableId = 'tbl-selection-365';
+ seedTable(db, selectionTableId, 4);
+ const readHeldForTable = async (targetTableId: string) =>
+ (await api(baseUrl, '/api/held-orders', { headers: authHeader })).data.orders
+ .find((order: any) => order.tableId === targetTableId);
+
+ const firstSelectionHold = await api(baseUrl, '/api/held-orders', {
+ method: 'POST',
+ body: {
+ tableId: selectionTableId,
+ items: mockItems,
+ waivedChargeIds: ['service_charge', 'late_fee', 'late_fee'],
+ optedInChargeIds: ['optional_packing'],
+ },
+ headers: authHeader,
+ });
+ assertEqualOrThrow(firstSelectionHold.status, 200, 'POST /held-orders accepts charge selections');
+ const heldSelection = await readHeldForTable(selectionTableId);
+ assertEqualOrThrow(JSON.stringify(heldSelection.waivedChargeIds), JSON.stringify(['service_charge', 'late_fee']),
+ 'waived charge ids round-trip and deduplicate');
+ assertEqualOrThrow(JSON.stringify(heldSelection.optedInChargeIds), JSON.stringify(['optional_packing']), 'opted-in charge ids round-trip');
+ console.log(' ✓ held charge selections round-trip through hold and fetch');
+
+ const overwriteSelection = await api(baseUrl, '/api/held-orders', {
+ method: 'POST',
+ body: {
+ tableId: selectionTableId,
+ items: mockItems,
+ waivedChargeIds: [],
+ optedInChargeIds: ['optional_packing', 'gift_wrap'],
+ },
+ headers: authHeader,
+ });
+ assertEqualOrThrow(overwriteSelection.status, 200, 'POST /held-orders overwrites an existing hold');
+ const afterOverwriteSelection = await readHeldForTable(selectionTableId);
+ assertEqualOrThrow(JSON.stringify(afterOverwriteSelection.waivedChargeIds), '[]', 'an emptied waived selection is persisted');
+ assertEqualOrThrow(JSON.stringify(afterOverwriteSelection.optedInChargeIds), JSON.stringify(['optional_packing', 'gift_wrap']),
+ 'the overwritten opted-in selection is persisted');
+ console.log(' ✓ overwriting a hold replaces both saved selections');
+
+ const legacyPayloadHold = await api(baseUrl, '/api/held-orders', {
+ method: 'POST',
+ body: { tableId: selectionTableId, items: mockItems },
+ headers: authHeader,
+ });
+ assertEqualOrThrow(legacyPayloadHold.status, 200, 'POST /held-orders still accepts the pre-selection payload');
+ const afterLegacyPayload = await readHeldForTable(selectionTableId);
+ assertEqualOrThrow(JSON.stringify(afterLegacyPayload.waivedChargeIds), '[]', 'an omitted waived selection defaults to empty');
+ assertEqualOrThrow(JSON.stringify(afterLegacyPayload.optedInChargeIds), '[]', 'an omitted opted-in selection defaults to empty');
+ assertEqualOrThrow(
+ db.prepare('SELECT waived_charge_ids FROM held_orders WHERE table_id = ?').get(selectionTableId).waived_charge_ids,
+ '[]',
+ 'an omitted selection is stored as an empty array, not NULL',
+ );
+ console.log(' ✓ pre-selection clients keep working and store empty selections');
+
+ // A hold whose stored selection JSON is damaged by a foreign build must
+ // still be resumable, with only the damaged selection dropped.
+ db.prepare('UPDATE held_orders SET waived_charge_ids = ?, opted_in_charge_ids = ? WHERE table_id = ?')
+ .run('not json', '{"packing":true}', selectionTableId);
+ const malformedSelectionHold = await readHeldForTable(selectionTableId);
+ assertOrThrow(Boolean(malformedSelectionHold), 'a hold with malformed selection JSON is still returned');
+ assertEqualOrThrow(JSON.stringify(malformedSelectionHold.waivedChargeIds), '[]', 'malformed stored waived selection degrades to empty');
+ assertEqualOrThrow(JSON.stringify(malformedSelectionHold.optedInChargeIds), '[]', 'malformed stored opted-in selection degrades to empty');
+ assertEqualOrThrow(malformedSelectionHold.items[0].id, 'latte-line', 'a malformed selection keeps the held cart items');
+ console.log(' ✓ malformed stored selections degrade to empty without dropping the hold');
+
+ // Restore a good hold, then prove rejected payloads cannot clobber it.
+ const restoreGoodSelections = await api(baseUrl, '/api/held-orders', {
+ method: 'POST',
+ body: { tableId: selectionTableId, items: mockItems, waivedChargeIds: ['late_fee'], optedInChargeIds: ['optional_packing'] },
+ headers: authHeader,
+ });
+ const protectedHoldId = restoreGoodSelections.data.id;
+ const invalidSelections = [
+ { waivedChargeIds: 'service_charge' },
+ { waivedChargeIds: [42] },
+ { optedInChargeIds: ['bad id!'] },
+ { optedInChargeIds: ['a'.repeat(65)] },
+ { optedInChargeIds: [{ id: 'service_charge' }] },
+ { waivedChargeIds: Array.from({ length: 51 }, (_, index) => `charge_${index}`) },
+ ];
+ for (const invalidSelection of invalidSelections) {
+ const invalidSelectionRes = await api(baseUrl, '/api/held-orders', {
+ method: 'POST',
+ body: { tableId: selectionTableId, items: mockItems, ...invalidSelection },
+ headers: authHeader,
+ });
+ assertEqualOrThrow(invalidSelectionRes.status, 400,
+ `rejects an invalid charge selection: ${JSON.stringify(invalidSelection).slice(0, 60)}`);
+ }
+ const afterRejectedSelections = await readHeldForTable(selectionTableId);
+ assertEqualOrThrow(afterRejectedSelections.id, protectedHoldId, 'a rejected hold leaves the prior identity intact');
+ assertEqualOrThrow(JSON.stringify(afterRejectedSelections.waivedChargeIds), JSON.stringify(['late_fee']), 'a rejected hold leaves the prior waived selection intact');
+ assertEqualOrThrow(JSON.stringify(afterRejectedSelections.optedInChargeIds), JSON.stringify(['optional_packing']), 'a rejected hold leaves the prior opted-in selection intact');
+ console.log(' ✓ malformed charge selections are rejected without touching the stored hold');
+
+ // A role without held-orders.manage cannot read or write selections.
+ const jwt = require('jsonwebtoken');
+ const { getJWTSecret } = require('../main/routes/auth');
+ db.prepare(`
+ INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at)
+ VALUES ('chef-held-365', 'Test Chef', 'chef-held@test.local', 'x', 'chef', 1, ?, ?)
+ `).run(now(), now());
+ const chefAuthHeader = {
+ Authorization: `Bearer ${jwt.sign({ userId: 'chef-held-365', email: 'chef-held@test.local', role: 'chef' }, getJWTSecret(), { expiresIn: '1h' })}`,
+ };
+ const deniedHold = await api(baseUrl, '/api/held-orders', {
+ method: 'POST',
+ body: { tableId: selectionTableId, items: mockItems, waivedChargeIds: ['late_fee'] },
+ headers: chefAuthHeader,
+ });
+ assertEqualOrThrow(deniedHold.status, 403, 'a role without held-orders.manage cannot hold an order');
+ const deniedRead = await api(baseUrl, '/api/held-orders', { headers: chefAuthHeader });
+ assertEqualOrThrow(deniedRead.status, 403, 'a role without held-orders.manage cannot read held orders');
+ assertEqualOrThrow((await readHeldForTable(selectionTableId)).id, protectedHoldId, 'a denied hold leaves the stored cart untouched');
+ console.log(' ✓ the held-order authorization boundary covers the new fields');
+
+ await api(
+ baseUrl,
+ `/api/held-orders/${selectionTableId}?heldOrderId=${encodeURIComponent(protectedHoldId)}`,
+ { method: 'DELETE', headers: authHeader },
+ );
+
// ═══════════════════════════════════════════════════════════════════
console.log('\n─── Scenario C: POST /held-orders validates request data ───');
const invalidRequests = [
diff --git a/tests/menu-printing.test.ts b/tests/menu-printing.test.ts
index 6b15034fb..bdf319cfc 100644
--- a/tests/menu-printing.test.ts
+++ b/tests/menu-printing.test.ts
@@ -219,18 +219,31 @@ test('menu route preserves regional conflicts, validates filters, and honors the
const originals = { getDatabase: database.getDatabase, requirePermission: authorization.requirePermission, printMenuDocument: thermal.printMenuDocument };
let configured = false;
let printed: any;
+ const variantRows = [
+ { id: 'v-small', product_id: 'coffee', name: 'Small', price: 5, is_active: 1, sort_order: 0, track_inventory: 0, stock_quantity: 0, inventory_product_id: null },
+ { id: 'v-large', product_id: 'coffee', name: 'Large', price: 8, is_active: 1, sort_order: 1, track_inventory: 0, stock_quantity: 0, inventory_product_id: null },
+ { id: 'v-retired', product_id: 'coffee', name: 'Retired', price: 3, is_active: 0, sort_order: 2, track_inventory: 0, stock_quantity: 0, inventory_product_id: null },
+ { id: 'v-sold-out', product_id: 'coffee', name: 'Sold Out', price: 6, is_active: 1, sort_order: 3, track_inventory: 1, stock_quantity: 0, inventory_product_id: null },
+ { id: 'v-linked', product_id: 'coffee', name: 'Linked', price: 7, is_active: 1, sort_order: 4, track_inventory: 1, stock_quantity: 0, inventory_product_id: 'product-beans' },
+ ];
database.getDatabase = () => ({ prepare: (sql: string) => ({
all: () => {
if (sql.includes('FROM settings')) return configured ? [{ key: 'country', value: 'US' }, { key: 'currency', value: 'USD' }] : [];
if (sql.includes('FROM categories')) return [{ id: 'drinks', name: 'Drinks', is_active: 1, sort_order: 0 }];
- if (sql.includes('FROM products p')) return [{ id: 'tea', category_id: 'drinks', name: 'Tea', description: 'Fresh tea', price: 5, is_active: 1, track_inventory: 0, stock_quantity: 0, sort_order: 0 }];
+ if (sql.includes('FROM products p')) return [
+ { id: 'tea', category_id: 'drinks', name: 'Tea', description: 'Fresh tea', price: 5, is_active: 1, track_inventory: 0, stock_quantity: 0, sort_order: 0 },
+ // A parent whose own price is unsellable: only its variants are sold.
+ { id: 'coffee', category_id: 'drinks', name: 'Coffee', description: null, price: 0, is_active: 1, track_inventory: 0, stock_quantity: 0, sort_order: 1 },
+ ];
if (sql.includes('FROM addon_group_product')) return [{ product_id: 'tea', addon_group_id: 'milk' }];
if (sql.includes('FROM category_addon_groups')) return [];
if (sql.includes('FROM addon_groups')) return [{ id: 'milk', name: 'Milk', is_active: 1, sort_order: 0 }];
if (sql.includes('FROM addons')) return [{ id: 'oat', addon_group_id: 'milk', name: 'Oat', price: 2, is_active: 1 }];
- // The menu route reuses the catalog's shared relation loader, which also
- // loads product variants. A menu has no variant rows to print.
- if (sql.includes('FROM product_variants')) return [];
+ // The menu route reuses the catalog's shared relation loader, which
+ // returns only active variants unless the caller asks for all of them.
+ if (sql.includes('FROM product_variants')) return variantRows.filter(
+ (variant) => sql.includes('is_active = 1') ? variant.is_active === 1 : true,
+ );
throw new Error(`Unexpected query: ${sql}`);
},
get: () => {
@@ -257,6 +270,33 @@ test('menu route preserves regional conflicts, validates filters, and honors the
assert.equal(success.body.webusb, true);
assert.equal(printed.printer.name, 'Default WebUSB');
assert.deepEqual(printed.document.sections[0].products[0].details.map((detail: { text: string }) => detail.text), ['Fresh tea', 'Milk: Oat ($2.00)']);
+
+ // A product with active variants prints one sellable row per variant at the
+ // variant's own price; the parent's unsellable price is never advertised,
+ // and an inactive variant is not a menu row.
+ const rows = printed.document.sections[0].products as Array<{ name: { text: string }; price: string }>;
+ const rowNames = rows.map((row) => row.name.text);
+ assert.deepEqual(
+ rowNames.filter((name) => name.startsWith('Coffee')),
+ ['Coffee (Small)', 'Coffee (Large)', 'Coffee (Linked)'],
+ 'only active variants of a parent print as rows',
+ );
+ const coffeePrices = Object.fromEntries(rows.map((row) => [row.name.text, row.price]));
+ assert.equal(coffeePrices['Coffee (Small)'], '$5.00', 'a variant row shows the variant price, not the parent price');
+ assert.equal(coffeePrices['Coffee (Large)'], '$8.00', 'each variant carries its own price');
+ assert.equal(rowNames.includes('Coffee'), false, 'an unsellable parent 0 price is never a phantom row');
+ assert.equal(rowNames.includes('Coffee (Retired)'), false, 'an inactive variant is not printed');
+ assert.equal(rowNames.includes('Coffee (Sold Out)'), false, 'a sold-out tracked variant is withheld by default');
+ assert.equal(coffeePrices['Coffee (Linked)'], '$7.00', 'a recipe-linked variant sells from its recipe, not its own empty pool');
+ assert.equal(rowNames.includes('Tea'), true, 'a product without variants still prints its parent row');
+ assert.equal(printed.document.itemCount, rows.length, 'the printed item count counts emitted variant rows');
+
+ // includeOutOfStock applies to emitted sale rows too.
+ const withOutOfStock = await request(app).post('/api/printers/print-menu').send({ includeOutOfStock: true });
+ assert.equal(withOutOfStock.status, 200);
+ const outOfStockNames = (printed.document.sections[0].products as Array<{ name: { text: string } }>)
+ .map((row) => row.name.text);
+ assert.equal(outOfStockNames.includes('Coffee (Sold Out)'), true, 'includeOutOfStock surfaces a sold-out variant row');
} finally {
Object.assign(database, { getDatabase: originals.getDatabase });
Object.assign(authorization, { requirePermission: originals.requirePermission });
diff --git a/tests/shutdown-lifecycle.test.ts b/tests/shutdown-lifecycle.test.ts
index de22e259f..64e93abd8 100644
--- a/tests/shutdown-lifecycle.test.ts
+++ b/tests/shutdown-lifecycle.test.ts
@@ -19,6 +19,12 @@ import {
waitForHttpShutdownWork,
} from '../main/shutdown';
import { createAutoUpdaterErrorHandler, createRestartAndInstallHandler, type UpdateShutdownState } from '../main/updater-shutdown';
+import {
+ createFailedWindowRecovery,
+ shouldQuitOnAllWindowsClosed,
+ type FailedWindowRecovery,
+ type RecoverableWindow,
+} from '../main/window-recovery';
import { startStandaloneServers } from '../main/standalone-startup';
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-shutdown-lifecycle-'));
@@ -1231,6 +1237,169 @@ async function testQuitAndInstallCleanupOrdering(): Promise {
}
}
+/**
+ * Reproduces the native event order that shut load recovery down on
+ * Windows/Linux: destroying the last window raises `window-all-closed` while
+ * the replacement window does not exist yet, so a quit admitted there aborts
+ * recovery mid-flight.
+ */
+async function testFailedWindowRecovery(): Promise {
+ interface RecoveryHarness {
+ recovery: FailedWindowRecovery;
+ failedWindow: RecoverableWindow;
+ mainWindow: RecoverableWindow | null;
+ destroyed: { value: boolean };
+ creates: { value: number };
+ relaunches: string[];
+ logErrors: unknown[];
+ quitCalls: { value: number };
+ aborted: { value: boolean };
+ runtimeHealthy: { value: boolean };
+ /** Wired exactly like the production all-closed listener. */
+ handleAllWindowsClosed: (platform: string) => void;
+ }
+
+ const buildHarness = (
+ createWindow: (harness: RecoveryHarness) => void,
+ platform = 'win32',
+ ): RecoveryHarness => {
+ const destroyed = { value: false };
+ const harness: RecoveryHarness = {
+ failedWindow: {
+ isDestroyed: () => destroyed.value,
+ destroy: () => {
+ destroyed.value = true;
+ // Electron emits window-all-closed from the native destroy, before
+ // the recovery path can create the replacement.
+ harness.handleAllWindowsClosed(platform);
+ },
+ },
+ mainWindow: null,
+ destroyed,
+ creates: { value: 0 },
+ relaunches: [],
+ logErrors: [],
+ quitCalls: { value: 0 },
+ aborted: { value: false },
+ runtimeHealthy: { value: true },
+ recovery: undefined as unknown as FailedWindowRecovery,
+ handleAllWindowsClosed: (activePlatform: string) => {
+ if (shouldQuitOnAllWindowsClosed(activePlatform, harness.recovery.isReplacingWindow())) {
+ harness.quitCalls.value += 1;
+ }
+ },
+ };
+ harness.recovery = createFailedWindowRecovery({
+ getMainWindow: () => harness.mainWindow,
+ isAborted: () => harness.aborted.value,
+ isRuntimeHealthy: () => harness.runtimeHealthy.value,
+ requestRelaunch: (reason) => { harness.relaunches.push(reason); },
+ createWindow: () => {
+ harness.creates.value += 1;
+ createWindow(harness);
+ },
+ logError: (_message, error) => { harness.logErrors.push(error); },
+ });
+ harness.mainWindow = harness.failedWindow;
+ return harness;
+ };
+
+ const replaceWithLiveWindow = (harness: RecoveryHarness): void => {
+ const replacement: RecoverableWindow = { isDestroyed: () => false, destroy: () => {} };
+ harness.mainWindow = replacement;
+ // A replacement that itself closes must be able to quit the app again.
+ harness.handleAllWindowsClosed('win32');
+ };
+
+ // Success: the destroy raises all-closed while the replacement is pending,
+ // yet neither quit nor relaunch is admitted.
+ const success = buildHarness(replaceWithLiveWindow);
+ success.recovery.recover(success.failedWindow);
+ assert.equal(success.destroyed.value, true, 'recovery destroys the failed window');
+ assert.equal(success.creates.value, 1, 'recovery builds exactly one replacement window');
+ assert.equal(success.quitCalls.value, 0, 'destroying the failed window during recovery must not quit the app');
+ assert.deepEqual(success.relaunches, [], 'successful recovery does not request a relaunch');
+ assert.equal(success.recovery.isReplacingWindow(), false, 'recovery flag is restored after success');
+ assert.equal(
+ shouldQuitOnAllWindowsClosed('win32', success.recovery.isReplacingWindow()),
+ true,
+ 'a normal all-closed after recovery still quits on Windows',
+ );
+
+ // Create failure: relaunch is requested, the flag is restored, and the
+ // queued all-closed event still cannot quit.
+ const createFailure = buildHarness(() => { throw new Error('window construction failed'); });
+ createFailure.recovery.recover(createFailure.failedWindow);
+ assert.deepEqual(createFailure.relaunches, ['window-load-recovery-create-failed'],
+ 'recreation failure requests a relaunch');
+ assert.equal(createFailure.logErrors.length, 1, 'recreation failure is logged');
+ assert.equal(createFailure.quitCalls.value, 0, 'a failed replacement must not quit the app');
+ assert.equal(createFailure.recovery.isReplacingWindow(), false, 'recovery flag is restored after a failed create');
+ const afterFailure = buildHarness(replaceWithLiveWindow);
+ afterFailure.recovery.recover(afterFailure.failedWindow);
+ assert.equal(afterFailure.quitCalls.value, 0, 'the restored flag is a real flag, not a stuck one');
+
+ // Already-destroyed input: destroy is skipped but a replacement is still built.
+ let redundantDestroys = 0;
+ const alreadyDestroyed = buildHarness(replaceWithLiveWindow);
+ alreadyDestroyed.destroyed.value = true;
+ alreadyDestroyed.failedWindow.destroy = () => { redundantDestroys += 1; };
+ alreadyDestroyed.recovery.recover(alreadyDestroyed.failedWindow);
+ assert.equal(redundantDestroys, 0, 'an already-destroyed window is not destroyed twice');
+ assert.equal(alreadyDestroyed.creates.value, 1, 'an already-destroyed window is still replaced');
+ assert.equal(alreadyDestroyed.quitCalls.value, 0, 'replacement after double destroy does not quit');
+
+ // Normal close outside recovery keeps the platform quit policy.
+ const idle = buildHarness(replaceWithLiveWindow);
+ idle.handleAllWindowsClosed('win32');
+ idle.handleAllWindowsClosed('linux');
+ assert.equal(idle.quitCalls.value, 2, 'normal Windows/Linux all-closed still quits');
+ idle.handleAllWindowsClosed('darwin');
+ assert.equal(idle.quitCalls.value, 2, 'macOS never quits on all-closed');
+
+ // Unhealthy runtime relaunches instead of destroying a window it cannot replace.
+ const unhealthy = buildHarness(replaceWithLiveWindow);
+ unhealthy.runtimeHealthy.value = false;
+ unhealthy.recovery.recover(unhealthy.failedWindow);
+ assert.deepEqual(unhealthy.relaunches, ['window-load-retry-exhausted'], 'unhealthy runtime relaunches');
+ assert.equal(unhealthy.destroyed.value, false, 'unhealthy runtime leaves the window alone');
+
+ // Only one in-place recovery is attempted; later failures relaunch.
+ const secondAttempt = buildHarness(replaceWithLiveWindow);
+ secondAttempt.recovery.recover(secondAttempt.failedWindow);
+ const secondFailure: RecoverableWindow = { isDestroyed: () => false, destroy: () => {} };
+ secondAttempt.mainWindow = secondFailure;
+ secondAttempt.recovery.recover(secondFailure);
+ assert.deepEqual(secondAttempt.relaunches, ['window-load-recovery-failed'],
+ 'a second failure escalates to a relaunch');
+ assert.equal(secondAttempt.creates.value, 1, 'the second failure does not rebuild again');
+
+ // A successful load re-arms in-place recovery for the replacement window.
+ const rearmed = buildHarness(replaceWithLiveWindow);
+ rearmed.recovery.recover(rearmed.failedWindow);
+ rearmed.recovery.markLoadSucceeded();
+ const reloadedWindow: RecoverableWindow = { isDestroyed: () => false, destroy: () => {} };
+ rearmed.mainWindow = reloadedWindow;
+ rearmed.recovery.recover(reloadedWindow);
+ assert.equal(rearmed.creates.value, 2, 'a successful load re-arms in-place recovery');
+ assert.deepEqual(rearmed.relaunches, [], 'the re-armed attempt does not relaunch first');
+
+ // Quits and shutdowns already in progress own the lifecycle.
+ const aborted = buildHarness(replaceWithLiveWindow);
+ aborted.aborted.value = true;
+ aborted.recovery.recover(aborted.failedWindow);
+ assert.equal(aborted.destroyed.value, false, 'an in-progress quit is not interrupted by recovery');
+ assert.deepEqual(aborted.relaunches, [], 'an in-progress quit does not relaunch');
+
+ // A stale failed window is ignored once the main window moved on.
+ const stale = buildHarness(replaceWithLiveWindow);
+ stale.mainWindow = { isDestroyed: () => false, destroy: () => {} };
+ stale.recovery.recover(stale.failedWindow);
+ assert.equal(stale.destroyed.value, false, 'a window that is no longer main is not destroyed');
+
+ console.log('Failed-window recovery lifecycle tests passed.');
+}
+
(async () => {
console.log('phase coordinator');
await testCoordinatorOrderingAndIdempotency();
@@ -1255,6 +1424,8 @@ async function testQuitAndInstallCleanupOrdering(): Promise {
await testOwnedServerStopEntrypoints();
console.log('phase update install ordering');
await testQuitAndInstallCleanupOrdering();
+ console.log('phase window recovery');
+ await testFailedWindowRecovery();
console.log('Shutdown lifecycle tests passed.');
})().catch((error) => {
console.error(error);
diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts
index dd9f7f368..df3a8bc03 100644
--- a/tests/upgrade-path.test.ts
+++ b/tests/upgrade-path.test.ts
@@ -43,6 +43,9 @@ fs.copyFileSync(FIXTURE, upgradeDbPath);
const FixtureDatabase = require('better-sqlite3');
const fixtureDb = new FixtureDatabase(upgradeDbPath);
const legacyTaxBreakdown = JSON.stringify([{ title: 'Legacy Tax', rate: 5, amount: 5 }]);
+const legacyHeldItems = JSON.stringify([
+ { id: 'line-legacy-1', product: { id: 'p-latte', name: 'Latte', price: 150 }, quantity: 2, addons: [], special_instructions: 'oat' },
+]);
const legacyOrder = fixtureDb.prepare(`
INSERT INTO orders (order_number, subtotal, tax_amount, tax_breakdown, total)
VALUES ('ORD-LEGACY-TAX', 100, 5, ?, 105)
@@ -159,6 +162,17 @@ function main() {
INSERT INTO settings (key, value, updated_at) VALUES ('custom_charges', ?, '2026-08-01 12:00:00')
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`).run(existingChargeSetting);
+
+ // A cart held before charge selections were persisted: it must keep its
+ // customer, notes, items, and identity across the column-add migration.
+ beforeChargeSnapshot.prepare(`
+ INSERT OR IGNORE INTO customers (id, name, phone, created_at, updated_at)
+ VALUES ('cust-legacy-hold', 'Legacy Hold Customer', '+10000000001', '2026-07-01 09:00:00', '2026-07-01 09:00:00')
+ `).run();
+ beforeChargeSnapshot.prepare(`
+ INSERT INTO held_orders (id, table_id, items, customer_id, guest_count, order_notes, created_at, updated_at)
+ VALUES ('hold-legacy-1', 'tbl-legacy-4', ?, 'cust-legacy-hold', 4, 'Extra napkins', '2026-07-01 09:05:00', '2026-07-01 09:05:00')
+ `).run(legacyHeldItems);
} finally {
closeDatabase();
MIGRATIONS.push(...migrationsFromCharges);
@@ -186,6 +200,13 @@ function main() {
assert.ok(tableColumns.includes('reservation_customer_id'), 'reservation customer association exists after upgrading an old install');
assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'clear_table_reservation_customer_on_status_change'").get(),
'reservation customer cleanup trigger exists after upgrading an old install');
+ const idealHeldColumns = ideal.prepare('PRAGMA table_info(held_orders)').all() as { name: string; notnull: number; dflt_value: string }[];
+ for (const column of ['waived_charge_ids', 'opted_in_charge_ids'] as const) {
+ const definition = idealHeldColumns.find((candidate) => candidate.name === column);
+ assert.ok(definition, `a fresh install declares held_orders.${column}`);
+ assert.equal(definition!.notnull, 1, `fresh held_orders.${column} is NOT NULL`);
+ assert.equal(definition!.dflt_value, "'[]'", `fresh held_orders.${column} defaults to an empty selection list`);
+ }
ideal.close();
assert.equal(
(db.prepare("SELECT value FROM settings WHERE key = 'split_checks_enabled'").get() as { value: string }).value,
@@ -385,6 +406,31 @@ function main() {
}
console.log(' ✓ cash ownership columns exist after upgrading an old install');
+ // ── Held carts survive the charge-selection columns ─────────────────────
+ const heldOrderColumns = db.prepare('PRAGMA table_info(held_orders)').all()
+ .map((column: any) => column.name);
+ for (const column of ['waived_charge_ids', 'opted_in_charge_ids'] as const) {
+ assert.ok(heldOrderColumns.includes(column), `held_orders.${column} exists after upgrading an old install`);
+ }
+ const preservedHold = db.prepare(`
+ SELECT id, table_id, items, customer_id, guest_count, order_notes,
+ waived_charge_ids, opted_in_charge_ids, created_at, updated_at
+ FROM held_orders WHERE id = 'hold-legacy-1'
+ `).get() as any;
+ assert.ok(preservedHold, 'the held cart saved before the migration still exists');
+ assert.equal(preservedHold.table_id, 'tbl-legacy-4', 'the held cart keeps its table');
+ assert.equal(JSON.parse(preservedHold.items)[0].id, 'line-legacy-1', 'the held cart keeps its items');
+ assert.equal(JSON.parse(preservedHold.items)[0].special_instructions, 'oat', 'held item notes survive');
+ assert.equal(preservedHold.customer_id, 'cust-legacy-hold', 'the held cart keeps its customer');
+ assert.equal(preservedHold.guest_count, 4, 'the held cart keeps its guest count');
+ assert.equal(preservedHold.order_notes, 'Extra napkins', 'the held cart keeps its notes');
+ assert.equal(preservedHold.created_at, '2026-07-01 09:05:00', 'the held cart keeps its created timestamp');
+ assert.equal(preservedHold.updated_at, '2026-07-01 09:05:00', 'the held cart keeps its updated timestamp');
+ assert.equal(preservedHold.waived_charge_ids, '[]', 'a legacy hold defaults to no waived charges');
+ assert.equal(preservedHold.opted_in_charge_ids, '[]', 'a legacy hold defaults to no opted-in charges');
+ assert.deepEqual(db.pragma('integrity_check'), [{ integrity_check: 'ok' }], 'integrity_check passes after the held-cart migration');
+ console.log(' ✓ v105 preserves a held cart while adding defaulted charge-selection columns');
+
// ── The columns must actually be usable, not just present ───────────────
const customerId = db.prepare(`SELECT id FROM customers LIMIT 1`).get() as { id: string } | undefined;
if (customerId) {
@@ -460,6 +506,90 @@ function main() {
).get() as { value: string };
assert.equal(diagnosticsSetting.value, 'false', 'v47 preserves an existing diagnostics opt-out');
console.log(' ✓ reopening is idempotent and preserves an already-active country pack');
+ const reopenedHeld = getDatabase().prepare(
+ `SELECT waived_charge_ids, opted_in_charge_ids FROM held_orders WHERE id = 'hold-legacy-1'`,
+ ).get() as { waived_charge_ids: string; opted_in_charge_ids: string };
+ assert.deepEqual(
+ reopenedHeld,
+ { waived_charge_ids: '[]', opted_in_charge_ids: '[]' },
+ 're-running the held-cart migration over an already-upgraded store is idempotent',
+ );
+ closeDatabase();
+
+ // ── Custom expected-method identity is additive and never guessed ───────
+ // Rewind to the schema that predates the identity column, with the historical
+ // name snapshots already on disk, and prove the migration adds only the ID.
+ const identityMigrationIndex = MIGRATIONS.findIndex(
+ (migration: any) => migration.name === 'add_order_expected_payment_method_id',
+ );
+ assert.ok(identityMigrationIndex > 0, 'the expected-method identity migration is registered');
+ assert.equal(MIGRATIONS[identityMigrationIndex].version, 106, 'the identity migration is v106');
+ assert.ok(
+ MIGRATIONS[identityMigrationIndex].name !== MIGRATIONS[identityMigrationIndex - 1].name,
+ 'the identity migration follows the held-cart migration as its own registry entry',
+ );
+ initDatabase();
+ const preIdentity = getDatabase();
+ assert.equal(getCurrentSchemaVersion(), 106, 'the store is at the identity schema before rewinding');
+ preIdentity.prepare('UPDATE orders SET expected_payment_method = ? WHERE id = ?')
+ .run('pending', legacyOrder.lastInsertRowid);
+ preIdentity.prepare('UPDATE orders SET expected_payment_method = ? WHERE id = ?')
+ .run('Store Credit', isoOrder.lastInsertRowid);
+ preIdentity.exec('ALTER TABLE orders DROP COLUMN expected_payment_method_id');
+ preIdentity.pragma('user_version = 105');
+ assert.ok(
+ !(preIdentity.prepare('PRAGMA table_info(orders)').all() as { name: string }[])
+ .some((column) => column.name === 'expected_payment_method_id'),
+ 'the rewind reproduces a store that predates the identity column',
+ );
+ closeDatabase();
+ const migrationsFromIdentity = MIGRATIONS.splice(identityMigrationIndex);
+ try {
+ initDatabase();
+ assert.equal(getCurrentSchemaVersion(), 105, 'the store stops at v105 without the identity column');
+ } finally {
+ MIGRATIONS.push(...migrationsFromIdentity);
+ }
+ closeDatabase();
+ initDatabase();
+ const identityDb = getDatabase();
+ const preIdentitySnapshot = identityDb.prepare(
+ 'SELECT expected_payment_method FROM orders WHERE id = ?',
+ ).get(legacyOrder.lastInsertRowid) as { expected_payment_method: string | null };
+ assert.equal(preIdentitySnapshot.expected_payment_method, 'pending',
+ 'the pre-migration sentinel name is still on disk before the identity column exists');
+ const identityOrders = identityDb.prepare(
+ 'SELECT order_number, expected_payment_method, expected_payment_method_id FROM orders ORDER BY id',
+ ).all() as Array<{ order_number: string; expected_payment_method: string | null; expected_payment_method_id: number | null }>;
+ const sentinelOrder = identityOrders.find((row) => row.order_number === 'ORD-LEGACY-TAX');
+ assert.equal(sentinelOrder?.expected_payment_method, 'pending',
+ 'a legacy sentinel name snapshot survives the identity migration unchanged');
+ assert.equal(sentinelOrder?.expected_payment_method_id, null,
+ 'the legacy `pending` string is never guessed into a custom method identity');
+ const customNameOrder = identityOrders.find((row) => row.order_number === 'ORD-ISO-TS');
+ assert.equal(customNameOrder?.expected_payment_method, 'Store Credit',
+ 'a legacy custom name snapshot survives the identity migration unchanged');
+ assert.equal(customNameOrder?.expected_payment_method_id, null,
+ 'a legacy custom name is not retroactively linked to a configured method row');
+ const orderColumnsAfterIdentity = identityDb.prepare('PRAGMA table_info(orders)').all() as
+ { name: string; notnull: number; dflt_value: string | null }[];
+ const identityColumn = orderColumnsAfterIdentity.find((column) => column.name === 'expected_payment_method_id');
+ assert.ok(identityColumn, 'orders.expected_payment_method_id exists after upgrading an old install');
+ assert.equal(identityColumn!.notnull, 0, 'the identity column is nullable so legacy rows stay unambiguous');
+ assert.ok(
+ identityColumn!.dflt_value === null || String(identityColumn!.dflt_value).toUpperCase() === 'NULL',
+ `the identity column has no default identity, found ${String(identityColumn!.dflt_value)}`,
+ );
+ assert.deepEqual(identityDb.pragma('integrity_check'), [{ integrity_check: 'ok' }],
+ 'integrity_check passes after the identity migration');
+ const freshIdentityDb = buildIdealSchemaDb();
+ assert.ok(
+ (freshIdentityDb.prepare('PRAGMA table_info(orders)').all() as { name: string }[])
+ .some((column) => column.name === 'expected_payment_method_id'),
+ 'a fresh install declares orders.expected_payment_method_id',
+ );
+ freshIdentityDb.close();
+ console.log(' ✓ v106 adds a nullable expected-method identity without reinterpreting old snapshots');
closeDatabase();
console.log('='.repeat(60));
From 207aac76c2d61979f8d0a5f070ba53e5f6573b5f Mon Sep 17 00:00:00 2001
From: khaira777 <777gurkirat@gmail.com>
Date: Wed, 7 Oct 2026 02:04:30 -0400
Subject: [PATCH 2/5] no-mistakes(review): Reject malformed expected names;
test blocked (ts-node missing)
---
main/routes/orders-validation.ts | 3 +++
tests/delivery-address-egress.test.ts | 1 +
2 files changed, 4 insertions(+)
diff --git a/main/routes/orders-validation.ts b/main/routes/orders-validation.ts
index aa1f2d136..29653b7cb 100644
--- a/main/routes/orders-validation.ts
+++ b/main/routes/orders-validation.ts
@@ -83,6 +83,9 @@ export function resolveExpectedPaymentMethodIdentity(
if (!method) {
throw new Error('expected_payment_method_id must reference an active payment method');
}
+ if (methodName !== undefined && methodName !== null && typeof methodName !== 'string') {
+ throw new Error('expected_payment_method must be a string');
+ }
const suppliedName = typeof methodName === 'string' ? methodName.trim() : '';
if (suppliedName && suppliedName.toLowerCase() !== method.name.toLowerCase()) {
throw new Error('expected_payment_method does not match expected_payment_method_id');
diff --git a/tests/delivery-address-egress.test.ts b/tests/delivery-address-egress.test.ts
index 2b6d615d3..e1d097a44 100644
--- a/tests/delivery-address-egress.test.ts
+++ b/tests/delivery-address-egress.test.ts
@@ -347,6 +347,7 @@ test('delivery details: a custom method identity survives names that collide wit
{ expected_payment_method_id: 0 },
{ expected_payment_method_id: String(pendingId) },
{ expected_payment_method_id: pendingId, expected_payment_method: 'Card' },
+ { expected_payment_method_id: pendingId, expected_payment_method: 7 },
]) {
const rejected = await createDelivery(details);
assert.equal(rejected.status, 400, `${JSON.stringify(details)} is refused`);
From 8e9a285c1042d6b88bd5395dd9036d9bb3e8882b Mon Sep 17 00:00:00 2001
From: khaira777 <777gurkirat@gmail.com>
Date: Wed, 7 Oct 2026 03:07:43 -0400
Subject: [PATCH 3/5] no-mistakes(document): Correct stale lifecycle and
collection-method API docs
---
docs/architecture/runtime-and-lifecycle.md | 4 ++--
docs/reference/api.md | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/architecture/runtime-and-lifecycle.md b/docs/architecture/runtime-and-lifecycle.md
index 0ddc1cbef..d710b9ca0 100644
--- a/docs/architecture/runtime-and-lifecycle.md
+++ b/docs/architecture/runtime-and-lifecycle.md
@@ -95,8 +95,8 @@ Playwright and CI runs survive the restart.
## Window load failures
A window whose document URL has become `chrome-error://` is treated as a failed load.
-`recoverFailedWindow` rebuilds the window once, guarded by `windowLoadRecoveryAttempted`. A second
-failure, or a runtime that is not healthy, requests a relaunch rather than looping.
+`recoverFailedWindow` allows one in-place rebuild while the runtime is healthy. A second failure, or
+a runtime that is not healthy, requests a relaunch rather than looping.
## Shutdown order
diff --git a/docs/reference/api.md b/docs/reference/api.md
index fccb8ca77..4d496a954 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -266,7 +266,7 @@ Router: `main/routes/orders.ts`. Full path: `/api/orders`.
| --- | --- | --- | --- | --- |
| `GET` | `/` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | query: `?status`, `?type`, `?today`, `?start_date`, `?end_date`, `?table_id`, `?before_id`, `?per_page`, `?search` | `{ orders, nextCursor? }`, newest page first. `?before_id` pages backwards. `?search` matches order number, customer name, or phone before pagination. |
| `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. Honours an `Idempotency-Key` header; reusing a key with a different body returns `409`. `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: the method is `unknown` (also the default), `pending`, `cash`, `card`, or an active custom payment method name, and is never recorded as a payment; `400` for any other method or a note over 200 characters. An optional `expected_payment_method_id` names an **active** configured method and is the durable identity: the server stores that method's canonical name, requires any supplied name to match it case-insensitively, and `400`s an unknown, inactive, or non-numeric id. It 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 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` | `/` | `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. Honours an `Idempotency-Key` header; reusing a 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/customer` | `ROLE_ACCESS.ownerManager` + order write limiter | path: `id`; body: `customer_id` | - |
From 81bce1b647dec4b44cf70e0c3d5cd8274d2ece80 Mon Sep 17 00:00:00 2001
From: khaira777 <777gurkirat@gmail.com>
Date: Wed, 7 Oct 2026 03:19:16 -0400
Subject: [PATCH 4/5] no-mistakes(ci): Removed the stale assertion that add-on
inventory migration v104 must remain the registry tail; the registry now
correctly ends at v106. `npm run test:addon-inventory-lifecycle` passed
(92/92), `npm run test:migration-registry` passed (323/323), and `git diff
--check` passed. Only `tests/addon-inventory-lifecycle.test.ts` is modified;
the two temporary test driver files are absent
---
tests/addon-inventory-lifecycle.test.ts | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/tests/addon-inventory-lifecycle.test.ts b/tests/addon-inventory-lifecycle.test.ts
index 0684886ad..d059c077f 100644
--- a/tests/addon-inventory-lifecycle.test.ts
+++ b/tests/addon-inventory-lifecycle.test.ts
@@ -23,7 +23,7 @@ const { addonGroupRoutes } = require('../main/routes/addon-groups');
const { orderRoutes } = require('../main/routes/orders');
const { isAddonSoldOut, isAddonLowStock, addonStockCeiling } = require('../frontend/src/lib/addon-inventory');
-/** Version the add-on inventory migration ships as, and the registry tail it must own. */
+/** Version the add-on inventory migration ships as. */
const ADDON_MIGRATION_VERSION = 104;
// The catalog assigns the ids, so these are filled in once the group is created.
let OAT = '';
@@ -77,8 +77,6 @@ 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');
- const tail = MIGRATIONS[MIGRATIONS.length - 1];
- assertEqualOrThrow(tail.version, ADDON_MIGRATION_VERSION, 'the add-on inventory migration is the current registry tail');
const addonColumns = columnsOf(db, 'addons').map((column) => column.name);
for (const column of ['track_inventory', 'stock_quantity', 'low_stock_threshold']) {
From c9200c819a576c80da62ec5ea9173abf2c837e4c Mon Sep 17 00:00:00 2001
From: khaira777 <777gurkirat@gmail.com>
Date: Wed, 7 Oct 2026 09:43:40 -0400
Subject: [PATCH 5/5] fix(pos): retain fallback option for custom expected
payment method in CartPanel
---
frontend/src/components/pos/CartPanel.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/frontend/src/components/pos/CartPanel.tsx b/frontend/src/components/pos/CartPanel.tsx
index 4ff05993a..a76617927 100644
--- a/frontend/src/components/pos/CartPanel.tsx
+++ b/frontend/src/components/pos/CartPanel.tsx
@@ -294,6 +294,7 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
cart.setExpectedPaymentMethod(method.name, method.id);
return;
}
+ if (customId === cart.expectedPaymentMethodId) return;
}
cart.setExpectedPaymentMethod(selected, null);
}}
@@ -310,6 +311,10 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
))}
)}
+ {cart.expectedPaymentMethodId !== null
+ && !customPaymentMethods.some((method) => method.id === cart.expectedPaymentMethodId) && (
+ {cart.expectedPaymentMethod || tCommon('unknown')}
+ )}