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/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 7f8d50715..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`, `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'`: 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` | - | @@ -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..a76617927 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,43 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem {t('expectedPayment')}
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..29653b7cb 100644 --- a/main/routes/orders-validation.ts +++ b/main/routes/orders-validation.ts @@ -62,6 +62,37 @@ 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'); + } + 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'); + } + 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/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']) { 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..e1d097a44 100644 --- a/tests/delivery-address-egress.test.ts +++ b/tests/delivery-address-egress.test.ts @@ -224,6 +224,162 @@ 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' }, + { expected_payment_method_id: pendingId, expected_payment_method: 7 }, + ]) { + 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));