diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index e7555afdc..bb3e768d3 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -140,7 +140,7 @@ permission check inside the `withTxn` callback, after the transaction opens, usi `.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths (along with paid-bill payment-method correction on `PATCH /api/bills/:id/payments/:index/method`, -which also requires the authenticated caller itself to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)`) +which also requires the authenticated caller itself to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` and hold `payments.take`) additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check, not a permission, because the Staff Approval PIN belongs to a @@ -214,11 +214,15 @@ terminals, while approval authority stays restricted to active `owner` and `mana `'success'`, `'failure'`, or `'rate_limited'`). 3. **Terminal lock and session boundary:** Only one user is signed in on a device at a time. The manual **Lock** action in the POS topbar/sidebar (and the optional `auto_lock_minutes` idle - setting in **Settings > POS**, off by default with choices `1`, `5`, `15`, or `30` minutes) - revokes the active token without closing the open cash shift. Unfinished POS cart items remain on - the terminal across lock and user switch, while privileged cart adjustments (line price overrides - and charge waivers), cached override PINs, and open privileged modals are cleared and stale - asynchronous responses from the previous session are ignored. + setting in **Settings > POS**, off by default with choices `1`, `5`, `15`, or `30` minutes, + readable by all authenticated staff independently of feature permissions) revokes the active token + without closing the open cash shift. Unfinished POS cart items remain on the terminal across lock + and user switch, while privileged cart adjustments (line price overrides and charge waivers), + cached override PINs, and open privileged modals are cleared. Pending asynchronous checkout + continuations are guarded by session epoch and originating tokens so a user switch halts downstream + billing and payment transitions under the new session, clears any untouched matching cart to + prevent duplicate ordering, and preserves committed-order recovery state under the originating + user's attempt record. 4. **Approval boundary:** `GET /api/staff/approvers` and every Staff Approval PIN check (refunds, item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides, charge waivers, and paid-bill payment-method corrections) enforce active `owner` or `manager` diff --git a/docs/reference/api.md b/docs/reference/api.md index 06aeb826b..13fccebdc 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -414,7 +414,7 @@ Bill generation, settlement, and completion are described end to end in | `POST` | `/:id/split-check` | `ROLE_ACCESS.ownerManagerCashier` | body: `checks` | Body `checks` must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. `400` when a request does not allocate exactly the source's own quantities. `403` when `split_checks_enabled` is off. `409` when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks. | | `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object (accepts optional `override_pin`, `approver_id`, `manager_id`, `user_id`), `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. | | `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. | -| `PATCH` | `/:id/payments/:index/method` | `ROLE_ACCESS.ownerManager` + `payments.take` + active `owner` or `manager` Staff Approval PIN | path: `id`, `index`; body: `method`, `override_pin`, optional `payment_method_id`, `transaction_id`, `reason`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | `{ bill, audit }` (with `idempotentReplay: true` on `Idempotency-Key` or natural replay). Corrects the tender method on payment line `:index` of a `paid` bill without changing the amount (`400` `amount_immutable` if a differing `amount` is supplied, `400` `wallet_method_immutable` if changing to `wallet`, `400` `card_transaction_id_required` when required for `card`, `400` `payment_method_unavailable` for unknown methods). Caller must be an authenticated active `owner` or `manager` (`403` `insufficient_role_permissions` for `cashier`, `server`, or `chef` callers) and must provide an active `owner` or `manager` Staff Approval PIN (`403` when missing, invalid, or belonging to a `cashier`, `server`, or `chef`; `429` when rate-limited). Returns `404` when the bill or payment line is not found, and `409` when the bill is not `paid` (`bill_not_paid`), the existing line is `wallet` (`wallet_method_immutable`), the payment's cash shift is closed, missing/unverifiable, or was recorded without an open shift (`shift_closed`), the store business day is closed (`day_closed`), the bill, line, or a split sibling has been refunded (`bill_refunded`, `payment_line_refunded`, `split_sibling_refunded`) or settled in a closed or different shift (`split_sibling_shift_closed`), the target custom payment method is inactive (`payment_method_inactive`), `transaction_id` is already used (`transaction_reference_used`, `transaction_reference_reused`), or `Idempotency-Key` conflicts (`idempotency_key_conflict`). Audited in `order_audit_log` as `bill.payment_method.corrected`. | +| `PATCH` | `/:id/payments/:index/method` | `ROLE_ACCESS.ownerManager` + `payments.take` + active `owner` or `manager` Staff Approval PIN | path: `id`, `index`; body: `method`, `override_pin`, optional `payment_method_id`, `transaction_id`, `reason`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | `{ bill, audit }` (with `idempotentReplay: true` on `Idempotency-Key` or natural replay). Corrects the tender method on payment line `:index` of a `paid` bill without changing the amount (`400` `amount_immutable` if a differing `amount` is supplied, `400` `wallet_method_immutable` if changing to `wallet`, `400` `card_transaction_id_required` when required for `card`, `400` `payment_method_unavailable` for unknown methods). Caller must be an authenticated active `owner` or `manager` holding `payments.take` (`403` `insufficient_role_permissions` for `cashier`, `server`, or `chef` callers, or when `payments.take` is revoked) and must provide an active `owner` or `manager` Staff Approval PIN (`403` when missing, invalid, or belonging to a `cashier`, `server`, or `chef`; `429` when rate-limited). Returns `404` when the bill or payment line is not found, and `409` when the bill is not `paid` (`bill_not_paid`), the existing line is `wallet` (`wallet_method_immutable`), the payment's cash shift is closed, missing/unverifiable, or was recorded without an open shift (`shift_closed`), the store business day is closed (`day_closed`), the bill, line, or a split sibling has been refunded (`bill_refunded`, `payment_line_refunded`, `split_sibling_refunded`) or settled in a closed or different shift (`split_sibling_shift_closed`), the target custom payment method is inactive (`payment_method_inactive`), `transaction_id` is already used (`transaction_reference_used`, `transaction_reference_reused`), or `Idempotency-Key` conflicts (`idempotency_key_conflict`). Audited in `order_audit_log` as `bill.payment_method.corrected`. | | `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`), when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. | | `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied`, optional `override_pin`, `approver_id`, `manager_id`, `user_id` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. Waiving a charge when the caller is outside `ROLE_ACCESS.ownerManager` (or when `override_pin` is supplied) requires an active `owner` or `manager` Staff Approval PIN holding `bills.discount.apply` (`403` with `requiresApproval: true` when missing, `403` on invalid PIN, `429` when rate-limited, `400` on conflicting approver ids). `409` on a paid scheduled order (`SCHEDULED_ORDER_PAID`) or when the order is cancelled. | | `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. | @@ -660,7 +660,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`. | `POST` | `/google-drive/qr-menu/generate` | `catalog.manage` or `google-drive.manage` | body: `html`, `pageSize`, `preferences` | Renders the bounded HTML in an isolated offscreen window, publishes the PDF, and returns current QR menu status. | | `POST` | `/google-drive/qr-menu/upload` | `catalog.manage` or `google-drive.manage` | body: `.pdf` `fileName`, `mimeType: "application/pdf"`, base64 `data` | Validates the 15 MB limit and `%PDF-` header, replaces the stable Drive file, and returns current status. | | `PUT` | `/google-drive/qr-menu/sharing` | `catalog.manage` or `google-drive.manage` | body: `enabled` (boolean) | Adds or removes the file-only public reader permission without changing the file ID. | -| `GET` | `/auto_lock_minutes` | `ROLE_ACCESS.allStaff` + settings read limiter | none | `{ setting: { key: "auto_lock_minutes", value, updated_at } }` (defaults to `"0"` when unset). | +| `GET` | `/auto_lock_minutes` | any authenticated role + settings read limiter | none | `{ setting: { key: "auto_lock_minutes", value, updated_at } }` (defaults to `"0"` when unset). Readable by any authenticated staff member independently of feature permissions. | | `GET` | `/` | `ROLE_ACCESS.allStaff` | none | All settings. | | `GET` | `/bill-templates` | `ROLE_ACCESS.ownerManager` | none | - | | `PUT` | `/printing` | `ROLE_ACCESS.ownerManager` + settings write limiter | none | Body is the whole printing settings object; rejected with `400` when it is not an object. | @@ -927,8 +927,9 @@ all bills paid in the business-day window (`payment_status IN ('paid', 'partiall 'refunded')`) ordered by ascending business date and numeric `bill_number` sequence (`paid_at`, `id`, and `bill_number` tie-breakers), with `page-break-inside: avoid; break-inside: avoid;` on each `.invoice-card`, `[PAID]`, `[PARTIALLY REFUNDED]`, and `[REFUNDED]` status badges, non-excluded line -items (unit price, pre-tax subtotal, and after-tax total), tax components, payment details, and -`[In Period]` / `[Outside Period]` refund details. A trailing page-broken appendix renders the +items (unit price, pre-tax subtotal, and after-tax total), itemised charges (including custom fees +from `charges_breakdown` alongside standard charges, with legacy charge fallbacks), tax components, +payment details, and `[In Period]` / `[Outside Period]` refund details. A trailing page-broken appendix renders the per-day tax breakdown table and any prior-period invoices refunded during the selected period. - Returns `200` with `Content-Type: text/html; charset=utf-8` and `Content-Disposition: attachment; filename="invoices-YYYY-MM-DD.html"` (or `invoices-YYYY-MM-DD_to_YYYY-MM-DD.html`). diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index c8356a2e9..3e1ed2c1a 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -91,6 +91,7 @@ import { isSupportedFailureOperation, persistOrderAttempt, readOrderAttempt, + buildCartFingerprintFromState, } from '@/lib/order-attempt'; // Loaded on demand: the menu printer pulls the thermal print kernel in, which the @@ -240,7 +241,7 @@ const parsePrepaidPaymentRequest = (serialized: string): PrepaidPaymentRequest | }; export default function POSPage() { - const { currentTenant, user } = useAuthStore(); + const { currentTenant, user, token } = useAuthStore(); const isRestaurant = (currentTenant?.business_type ?? 'restaurant') === 'restaurant'; const cart = useCartStore(); const heldOrders = useHeldOrdersStore(); @@ -341,13 +342,30 @@ export default function POSPage() { const [sentTicketId, setSentTicketId] = useState(null); const delivery = useSupportTicketStatus(sentTicketId); const activeUserId = user?.id == null ? null : String(user.id); - const lastActiveUserIdRef = useRef(activeUserId); + const activeToken = token ?? null; + const sessionEpochRef = useRef(0); + const lastActiveSessionRef = useRef<{ userId: string | null; token: string | null }>({ + userId: activeUserId, + token: activeToken, + }); + const activeSubmissionTokenRef = useRef(null); + useEffect(() => { - if (lastActiveUserIdRef.current === activeUserId) return; - lastActiveUserIdRef.current = activeUserId; + if ( + lastActiveSessionRef.current.userId === activeUserId && + lastActiveSessionRef.current.token === activeToken + ) { + return; + } + lastActiveSessionRef.current = { userId: activeUserId, token: activeToken }; + sessionEpochRef.current += 1; + activeSubmissionTokenRef.current = null; + setSubmitting(false); + setCheckoutOverridePin(undefined); setPaymentBill(null); setCheckoutTable(null); + setPendingOrder(null); setShowPrepaidCheckout(false); setPrepaidModalAttempt(null); setShowScheduledOrders(false); @@ -355,7 +373,16 @@ export default function POSPage() { cashDrawer.setOpen(false); shift.setOpenModalOpen(false); shift.setCloseModalOpen(false); - }, [activeUserId, cashDrawer, shift]); + }, [activeUserId, activeToken, cashDrawer, shift]); + + const pinnedAuthHeaders = (authToken: string | null): Record => + authToken ? { Authorization: `Bearer ${authToken}` } : {}; + const checkSessionCurrent = (originEpoch: number, originUserId: string | null): boolean => { + if (sessionEpochRef.current !== originEpoch) return false; + const currentUser = useAuthStore.getState().user; + const currentUserId = currentUser?.id == null ? null : String(currentUser.id); + return currentUserId === originUserId; + }; const prepaidAttemptRef = useRef(null); const prepaidAttemptKeyRef = useRef(null); const postpaidAttemptRef = useRef(null); @@ -386,31 +413,37 @@ export default function POSPage() { return appendAttemptStorageRef.current; }; - const readPostpaidAttempt = () => { + const readPostpaidAttempt = (targetUserId?: string | null) => { if (typeof window === 'undefined') return null; + const effectiveUserId = targetUserId || activeUserId; const storage = getAppendAttemptStorage(); let sharedLegacyAppendRetained = false; try { const migratedAppend = migrateLegacyAppendAttempt(storage, { - userId: activeUserId || undefined, + userId: effectiveUserId || undefined, }); sharedLegacyAppendRetained = migratedAppend !== null && storage.getItem(LEGACY_POSTPAID_ATTEMPT_STORAGE_KEY) !== null; } catch { throw new OrderAttemptStorageError(); } - if (postpaidAttemptRef.current?.userId === activeUserId) return postpaidAttemptRef.current; - postpaidAttemptRef.current = null; - postpaidAttemptKeyRef.current = null; - postpaidAttemptWasLegacyRef.current = false; - if (activeUserId) { - const userStorageKey = getPostpaidOrderAttemptStorageKey(activeUserId); - const scoped = readOrderAttempt(storage, userStorageKey, activeUserId, { + if (effectiveUserId === activeUserId && postpaidAttemptRef.current?.userId === activeUserId) + return postpaidAttemptRef.current; + if (effectiveUserId === activeUserId) { + postpaidAttemptRef.current = null; + postpaidAttemptKeyRef.current = null; + postpaidAttemptWasLegacyRef.current = false; + } + if (effectiveUserId) { + const userStorageKey = getPostpaidOrderAttemptStorageKey(effectiveUserId); + const scoped = readOrderAttempt(storage, userStorageKey, effectiveUserId, { isValid: isUsablePostpaidAttempt, }); if (scoped) { - postpaidAttemptRef.current = scoped; - postpaidAttemptKeyRef.current = userStorageKey; + if (effectiveUserId === activeUserId) { + postpaidAttemptRef.current = scoped; + postpaidAttemptKeyRef.current = userStorageKey; + } return scoped; } } @@ -419,44 +452,59 @@ export default function POSPage() { : readOrderAttempt( storage, POSTPAID_ATTEMPT_STORAGE_KEY, - activeUserId || '', + effectiveUserId || '', { sharedKey: true, isValid: isUsablePostpaidAttempt, }, ); if (legacy) { - postpaidAttemptRef.current = legacy; - postpaidAttemptKeyRef.current = POSTPAID_ATTEMPT_STORAGE_KEY; - postpaidAttemptWasLegacyRef.current = true; + if (effectiveUserId === activeUserId) { + postpaidAttemptRef.current = legacy; + postpaidAttemptKeyRef.current = POSTPAID_ATTEMPT_STORAGE_KEY; + postpaidAttemptWasLegacyRef.current = true; + } + return legacy; } - return postpaidAttemptRef.current; + return null; }; /** Only advance the in-memory attempt after a verified write: keys that never * reached a backend must not be executed from memory either. */ - const savePostpaidAttempt = (attempt: PostpaidAttempt): boolean => { + const savePostpaidAttempt = (attempt: PostpaidAttempt, targetUserId?: string | null): boolean => { const previous = postpaidAttemptRef.current; - if (!activeUserId) return false; - const storageKey = getPostpaidOrderAttemptStorageKey(activeUserId); + const effectiveUserId = targetUserId || attempt.userId || activeUserId; + if (!effectiveUserId) return false; + const storageKey = getPostpaidOrderAttemptStorageKey(effectiveUserId); try { persistOrderAttempt(getAppendAttemptStorage(), storageKey, attempt); } catch { - postpaidAttemptRef.current = previous; + if (effectiveUserId === activeUserId) { + postpaidAttemptRef.current = previous; + } return false; } - postpaidAttemptRef.current = attempt; - postpaidAttemptKeyRef.current = storageKey; + if (effectiveUserId === activeUserId) { + postpaidAttemptRef.current = attempt; + postpaidAttemptKeyRef.current = storageKey; + } return true; }; - const clearPostpaidAttempt = () => { - const completedAttempt = postpaidAttemptRef.current; + const clearPostpaidAttempt = (targetUserId?: string | null) => { + const effectiveUserId = targetUserId || activeUserId; + const completedAttempt = + (effectiveUserId === activeUserId ? postpaidAttemptRef.current : null) || + readPostpaidAttempt(effectiveUserId); const storageKeys = [ - postpaidAttemptKeyRef.current, - postpaidAttemptWasLegacyRef.current ? POSTPAID_ATTEMPT_STORAGE_KEY : null, + effectiveUserId ? getPostpaidOrderAttemptStorageKey(effectiveUserId) : null, + (effectiveUserId === activeUserId && postpaidAttemptWasLegacyRef.current) || !effectiveUserId + ? POSTPAID_ATTEMPT_STORAGE_KEY + : null, ]; if (!completedAttempt) { - postpaidAttemptKeyRef.current = null; - postpaidAttemptWasLegacyRef.current = false; + if (effectiveUserId === activeUserId) { + postpaidAttemptKeyRef.current = null; + postpaidAttemptWasLegacyRef.current = false; + } return true; } const storage = getAppendAttemptStorage(); @@ -469,7 +517,7 @@ export default function POSPage() { cleared = false; } } - if (cleared) { + if (cleared && effectiveUserId === activeUserId) { postpaidAttemptRef.current = null; postpaidAttemptKeyRef.current = null; postpaidAttemptWasLegacyRef.current = false; @@ -487,21 +535,25 @@ export default function POSPage() { return { ...attempt, discount: safeDiscount }; }; - const readPrepaidAttempt = (): PrepaidAttempt | null => { - if (prepaidAttemptRef.current?.userId === activeUserId) return prepaidAttemptRef.current; - prepaidAttemptRef.current = null; - prepaidAttemptKeyRef.current = null; - if (typeof window === 'undefined' || !activeUserId) return null; + const readPrepaidAttempt = (targetUserId?: string | null): PrepaidAttempt | null => { + const effectiveUserId = targetUserId || activeUserId; + if (effectiveUserId === activeUserId && prepaidAttemptRef.current?.userId === activeUserId) + return prepaidAttemptRef.current; + if (effectiveUserId === activeUserId) { + prepaidAttemptRef.current = null; + prepaidAttemptKeyRef.current = null; + } + if (typeof window === 'undefined' || !effectiveUserId) return null; const storage = getAppendAttemptStorage(); - const scopedKey = getPrepaidOrderAttemptStorageKey(activeUserId); - const scoped = readOrderAttempt(storage, scopedKey, activeUserId, { + const scopedKey = getPrepaidOrderAttemptStorageKey(effectiveUserId); + const scoped = readOrderAttempt(storage, scopedKey, effectiveUserId, { isValid: isUsablePrepaidAttempt, }); if (scoped) { const safeAttempt = stripPrepaidDiscountPin(scoped); if (safeAttempt !== scoped) { - if (!savePrepaidAttempt(safeAttempt)) throw new OrderAttemptStorageError(); - } else { + if (!savePrepaidAttempt(safeAttempt, effectiveUserId)) throw new OrderAttemptStorageError(); + } else if (effectiveUserId === activeUserId) { prepaidAttemptRef.current = safeAttempt; prepaidAttemptKeyRef.current = scopedKey; } @@ -514,7 +566,7 @@ export default function POSPage() { const legacy = readOrderAttempt( storage, PREPAID_ATTEMPT_STORAGE_KEY, - activeUserId, + effectiveUserId, { sharedKey: true, isValid: isUsablePrepaidAttempt, @@ -522,36 +574,48 @@ export default function POSPage() { ); if (!legacy) return null; const migrated = stripPrepaidDiscountPin(legacy); - if (!savePrepaidAttempt(migrated)) throw new OrderAttemptStorageError(); + if (!savePrepaidAttempt(migrated, effectiveUserId)) throw new OrderAttemptStorageError(); clearOrderAttempt(storage, PREPAID_ATTEMPT_STORAGE_KEY, migrated); return migrated; }; - const savePrepaidAttempt = (attempt: PrepaidAttempt): boolean => { + const savePrepaidAttempt = (attempt: PrepaidAttempt, targetUserId?: string | null): boolean => { const previous = prepaidAttemptRef.current; - if (!activeUserId) return false; - const storageKey = getPrepaidOrderAttemptStorageKey(activeUserId); + const effectiveUserId = targetUserId || attempt.userId || activeUserId; + if (!effectiveUserId) return false; + const storageKey = getPrepaidOrderAttemptStorageKey(effectiveUserId); const safeAttempt = stripPrepaidDiscountPin(attempt); try { persistOrderAttempt(getAppendAttemptStorage(), storageKey, safeAttempt); } catch { - prepaidAttemptRef.current = previous; + if (effectiveUserId === activeUserId) { + prepaidAttemptRef.current = previous; + } return false; } - prepaidAttemptRef.current = safeAttempt; - prepaidAttemptKeyRef.current = storageKey; + if (effectiveUserId === activeUserId) { + prepaidAttemptRef.current = safeAttempt; + prepaidAttemptKeyRef.current = storageKey; + } return true; }; /** Intermediate prepaid states are persisted too: a request that runs under a * key the next run cannot replay can capture the same payment twice. */ - const requirePrepaidAttemptSaved = (attempt: PrepaidAttempt) => { - if (!savePrepaidAttempt(attempt)) throw new OrderAttemptStorageError(); + const requirePrepaidAttemptSaved = (attempt: PrepaidAttempt, targetUserId?: string | null) => { + if (!savePrepaidAttempt(attempt, targetUserId)) throw new OrderAttemptStorageError(); }; - const clearPrepaidAttempt = () => { - const completedAttempt = prepaidAttemptRef.current; - const storageKey = prepaidAttemptKeyRef.current; + const clearPrepaidAttempt = (targetUserId?: string | null) => { + const effectiveUserId = targetUserId || activeUserId; + const completedAttempt = + (effectiveUserId === activeUserId ? prepaidAttemptRef.current : null) || + (effectiveUserId ? readPrepaidAttempt(effectiveUserId) : null); + const storageKey = effectiveUserId + ? getPrepaidOrderAttemptStorageKey(effectiveUserId) + : prepaidAttemptKeyRef.current; if (!completedAttempt || !storageKey) { - prepaidAttemptRef.current = null; - prepaidAttemptKeyRef.current = null; + if (effectiveUserId === activeUserId) { + prepaidAttemptRef.current = null; + prepaidAttemptKeyRef.current = null; + } return true; } // The payment is already captured: report a cleanup problem without turning @@ -560,8 +624,10 @@ export default function POSPage() { console.error('Failed to close the prepaid retry attempt after the payment was captured'); return false; } - prepaidAttemptRef.current = null; - prepaidAttemptKeyRef.current = null; + if (effectiveUserId === activeUserId) { + prepaidAttemptRef.current = null; + prepaidAttemptKeyRef.current = null; + } return true; }; @@ -1401,39 +1467,17 @@ export default function POSPage() { }; const buildCurrentCartFingerprint = (orderItems = cart.items.map(cartItemToOrderItem)) => - JSON.stringify({ - table_id: cart.tableId, - customer_id: cart.customerId, - type: cart.orderType, - guest_count: cart.guestCount, - special_instructions: cart.orderNotes, - scheduled_for: - cart.orderType === 'takeaway' || cart.orderType === 'delivery' - ? cart.scheduledFor.trim() || undefined - : undefined, - online_platform: cart.orderType === 'online' ? cart.onlinePlatform : undefined, - 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), - opted_in_charge_ids: Array.from(cart.optedInChargeIds), - }); + buildCartFingerprintFromState(cart, orderItems); const handlePlaceOrder = async () => { if (!validateOrderPreflight()) return; + const cartFingerprint = buildCurrentCartFingerprint(); // Prepaid stores collect payment before finishing the order. if (shouldTakePaymentNow && !pendingOrder) { let matchingAttempt: PrepaidAttempt | null = null; try { const storedAttempt = readPrepaidAttempt(); - const cartFingerprint = buildCurrentCartFingerprint(); if ( storedAttempt && storedAttempt.userId === activeUserId && @@ -1453,11 +1497,21 @@ export default function POSPage() { // Postpaid store / unpaid order → place order, kitchen gets the ticket, payment collected later setSubmitting(true); + const originEpoch = sessionEpochRef.current; + const originUserId = activeUserId; + const originToken = + activeToken || (typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null); + const submissionToken = Symbol('pos-place-order'); + activeSubmissionTokenRef.current = submissionToken; + + const isSessionCurrent = () => checkSessionCurrent(originEpoch, originUserId); + let failureOperation = pendingOrder ? 'order.append' : 'order.create'; let failureStage = 'submission'; let failureCorrelationId: string | null = null; try { let orderForKot: Order; + let postpaidOperationId: string | null = null; if (pendingOrder) { // Add new items to an existing order with a durable retry key. @@ -1472,7 +1526,7 @@ export default function POSPage() { const candidateOperationId = createOperationReference() || undefined; failureCorrelationId = candidateOperationId || null; let itemAttempt = getOrCreateAppendAttempt(storage, { - userId: activeUserId || '', + userId: originUserId || '', orderId: pendingOrder.id, fingerprint: itemFingerprint, createKey: newIdempotencyKey, @@ -1496,18 +1550,28 @@ export default function POSPage() { } } failureCorrelationId = itemAttempt.operationId || null; - addItemsAttemptRef.current = itemAttempt; + if (originUserId === activeUserId) { + addItemsAttemptRef.current = itemAttempt; + } failureStage = 'append_items'; const { data } = await api.post( `/orders/${pendingOrder.id}/items`, { items: newItems, special_instructions: specialInstructions }, { headers: { + ...pinnedAuthHeaders(originToken), 'Idempotency-Key': itemAttempt.idempotencyKey, ...operationHeaders(itemAttempt.operationId), }, }, ); + if (!isSessionCurrent()) { + // Item append succeeded on server while session changed. Stop before mutating state. + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } toast.success(t('itemsAddedToOrder', { number: pendingOrder.order_number })); const updatedOrder = data.order as Order; const existingItemIds = new Set((pendingOrder.items || []).map((item) => Number(item.id))); @@ -1566,9 +1630,9 @@ export default function POSPage() { items: cart.items.map(cartItemToOrderItem), }; const orderFingerprint = JSON.stringify(orderPayload); - const priorOrderAttempt = readPostpaidAttempt(); + const priorOrderAttempt = readPostpaidAttempt(originUserId); if ( - priorOrderAttempt?.userId === activeUserId && + priorOrderAttempt?.userId === originUserId && priorOrderAttempt.fingerprint === orderFingerprint && priorOrderAttempt.order ) { @@ -1591,21 +1655,22 @@ export default function POSPage() { failureOperation = 'order.create'; failureStage = 'order_submission'; const orderAttempt: PostpaidAttempt = - priorOrderAttempt?.userId === activeUserId && + priorOrderAttempt?.userId === originUserId && priorOrderAttempt.fingerprint === orderFingerprint ? priorOrderAttempt : { - userId: activeUserId || '', + userId: originUserId || '', fingerprint: orderFingerprint, idempotencyKey: newIdempotencyKey(), operationId: createOperationReference() || undefined, }; const operationId = orderAttempt.operationId || createOperationReference(); + postpaidOperationId = operationId || null; failureCorrelationId = operationId; const attemptWithOperationId = operationId ? { ...orderAttempt, operationId } : orderAttempt; - if (!savePostpaidAttempt(attemptWithOperationId)) { + if (!savePostpaidAttempt(attemptWithOperationId, originUserId)) { if (operationId) void captureClientOperationFailure({ operation: 'order.create', @@ -1618,14 +1683,17 @@ export default function POSPage() { ? { data: { order: attemptWithOperationId.order } } : await api.post('/orders', orderPayload, { headers: { + ...pinnedAuthHeaders(originToken), 'Idempotency-Key': attemptWithOperationId.idempotencyKey, ...operationHeaders(operationId), }, }); const confirmedAttempt = { ...attemptWithOperationId, order: data.order as Order }; - if (!attemptWithOperationId.order && !savePostpaidAttempt(confirmedAttempt)) { + if (!attemptWithOperationId.order && !savePostpaidAttempt(confirmedAttempt, originUserId)) { console.error('The confirmed order could not be copied into retry storage'); - postpaidAttemptRef.current = confirmedAttempt; + if (originUserId === activeUserId) { + postpaidAttemptRef.current = confirmedAttempt; + } if (operationId) void captureClientOperationFailure({ operation: 'order.create', @@ -1633,6 +1701,15 @@ export default function POSPage() { correlationId: operationId, }); } + if (!isSessionCurrent()) { + // Critical recovery invariant: order succeeded on server while session changed. + // Originating user's recovery attempt is saved under getPostpaidOrderAttemptStorageKey(originUserId). + // If the shared cart still matches the submitted cart, clear it so the new session doesn't double-order. + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } if (data.order.release_status === 'scheduled' && data.order.scheduled_for) { toast.success( t('scheduledOrderPlaced', { @@ -1647,27 +1724,6 @@ export default function POSPage() { toast.success(t('orderPlaced', { number: data.order.order_number })); } orderForKot = data.order as Order; - if (!clearPostpaidAttempt()) { - if (operationId) - void captureClientOperationFailure({ - operation: 'order.create', - phase: 'retry_cleanup', - correlationId: operationId, - }); - reportOrderFailure({ - code: 'order.retry_state.cleanup_failed', - title: t('orderPlaced', { number: data.order.order_number }), - message: t('saleCompleteCleanupFailed'), - nextStep: t('saleCompleteCheckOrders'), - detail: 'The order was created but local retry state could not be cleared', - status: null, - operation: 'order.create', - stage: 'cleanup', - outcome: 'applied', - reasonCode: 'order.retry_state.cleanup_failed', - correlationId: operationId, - }); - } } if (cart.tableId) { @@ -1676,7 +1732,7 @@ export default function POSPage() { cart.tableId, cart.heldOrderId || undefined, ); - if (!deleted) await heldOrders.fetchHeldOrders(); + if (!deleted && isSessionCurrent()) await heldOrders.fetchHeldOrders(); } catch (heldOrderError) { // The order has already been placed. Do not turn a cleanup failure // into a failed sale or leave an unhandled promise in the console. @@ -1714,13 +1770,42 @@ export default function POSPage() { }); } } + if (!isSessionCurrent()) { + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } cart.clearCart(); + if (!pendingOrder && !clearPostpaidAttempt(originUserId)) { + if (postpaidOperationId) + void captureClientOperationFailure({ + operation: 'order.create', + phase: 'retry_cleanup', + correlationId: postpaidOperationId, + }); + reportOrderFailure({ + code: 'order.retry_state.cleanup_failed', + title: t('orderPlaced', { number: orderForKot.order_number }), + message: t('saleCompleteCleanupFailed'), + nextStep: t('saleCompleteCheckOrders'), + detail: 'The order was created but local retry state could not be cleared', + status: null, + operation: 'order.create', + stage: 'cleanup', + outcome: 'applied', + reasonCode: 'order.retry_state.cleanup_failed', + correlationId: postpaidOperationId, + }); + } setMobileCartOpen(false); await refreshTables(); void refreshScheduledOrders(); + if (!isSessionCurrent()) return; await printKotIfEnabled(orderForKot); } catch (error) { + if (!isSessionCurrent()) return; if (error instanceof OrderAttemptStorageError) reportOrderStorageFailure( t('placeOrderFailed'), @@ -1736,7 +1821,10 @@ export default function POSPage() { failureCorrelationId, ); } finally { - setSubmitting(false); + if (isSessionCurrent() && activeSubmissionTokenRef.current === submissionToken) { + setSubmitting(false); + activeSubmissionTokenRef.current = null; + } } }; @@ -1749,6 +1837,15 @@ export default function POSPage() { const isPrepaidCheckout = shouldTakePaymentNow; setShowPrepaidCheckout(false); setSubmitting(true); + const originEpoch = sessionEpochRef.current; + const originUserId = activeUserId; + const originToken = + activeToken || (typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null); + const submissionToken = Symbol('pos-prepaid-checkout'); + activeSubmissionTokenRef.current = submissionToken; + + const isSessionCurrent = () => checkSessionCurrent(originEpoch, originUserId); + const orderItems = cart.items.map(cartItemToOrderItem); let paymentLines = payments .filter((p) => p.amount > 0) @@ -1767,17 +1864,19 @@ export default function POSPage() { const cartFingerprint = buildCurrentCartFingerprint(orderItems); let storedAttempt: PrepaidAttempt | null; try { - storedAttempt = readPrepaidAttempt(); + storedAttempt = readPrepaidAttempt(originUserId); } catch (error) { // An unreadable attempt cannot be safely replaced with a fresh key. if (!(error instanceof OrderAttemptStorageError)) throw error; - reportOrderStorageFailure(t('processOrderFailed')); - setSubmitting(false); + if (isSessionCurrent()) { + reportOrderStorageFailure(t('processOrderFailed')); + setSubmitting(false); + } return; } const existingAttempt = storedAttempt && - storedAttempt.userId === activeUserId && + storedAttempt.userId === originUserId && storedAttempt.cartFingerprint === cartFingerprint && storedAttempt.orderIdempotencyKey && storedAttempt.paymentIdempotencyKey @@ -1788,21 +1887,23 @@ export default function POSPage() { ? parsePrepaidPaymentRequest(existingAttempt.paymentFingerprint) : null; if (existingAttempt?.bill && existingAttempt.paymentFingerprint && !savedPaymentRequest) { - reportOrderFailure({ - code: 'request.outcome_unknown', - title: t('processOrderFailed'), - message: t('operationOutcomeUnknown'), - nextStep: t('operationCheckBeforeRetry'), - detail: - 'The saved payment request could not be validated, so no replacement payment was submitted', - status: null, - operation: 'payment.create', - stage: 'recovery', - outcome: 'unknown', - reasonCode: 'request.outcome_unknown', - correlationId: existingAttempt.operationId, - }); - setSubmitting(false); + if (isSessionCurrent()) { + reportOrderFailure({ + code: 'request.outcome_unknown', + title: t('processOrderFailed'), + message: t('operationOutcomeUnknown'), + nextStep: t('operationCheckBeforeRetry'), + detail: + 'The saved payment request could not be validated, so no replacement payment was submitted', + status: null, + operation: 'payment.create', + stage: 'recovery', + outcome: 'unknown', + reasonCode: 'request.outcome_unknown', + correlationId: existingAttempt.operationId, + }); + setSubmitting(false); + } return; } if (savedPaymentRequest && existingAttempt) { @@ -1813,12 +1914,17 @@ export default function POSPage() { const recoveryOperationId = existingAttempt.operationId || createOperationReference(); try { const { data } = await api.get(`/bills/${existingAttempt.bill.id}`, { - headers: operationHeaders(recoveryOperationId), + headers: { + ...pinnedAuthHeaders(originToken), + ...operationHeaders(recoveryOperationId), + }, }); + if (!isSessionCurrent()) return; if (!data?.bill || !['paid', 'partial', 'unpaid'].includes(data.bill.payment_status)) { throw new Error('Bill status unavailable'); } } catch (recoveryError) { + if (!isSessionCurrent()) return; if (!(recoveryError as { response?: unknown } | null)?.response && recoveryOperationId) { void captureClientOperationFailure({ operation: 'payment.create', @@ -1843,6 +1949,7 @@ export default function POSPage() { return; } } + if (!isSessionCurrent()) return; const currentDiscount = savedPaymentRequest ? existingAttempt?.discount || null : discount && discount.value > 0 @@ -1868,7 +1975,7 @@ export default function POSPage() { : newIdempotencyKey(), } : { - userId: activeUserId || '', + userId: originUserId || '', cartFingerprint, paymentFingerprint, discount: discount && discount.value > 0 ? discount : null, @@ -1880,7 +1987,8 @@ export default function POSPage() { if (operationId && attempt.operationId !== operationId) attempt = { ...attempt, operationId }; // Persist the keys before the first order mutation. The server replays the // order response if this renderer loses the response or restarts. - if (!savePrepaidAttempt(attempt)) { + if (!savePrepaidAttempt(attempt, originUserId)) { + if (!isSessionCurrent()) return; reportOrderStorageFailure(t('processOrderFailed'), 'order.create', operationId || null); setSubmitting(false); return; @@ -1895,7 +2003,7 @@ export default function POSPage() { failureCorrelationId = operationId; if (operationId && attempt.operationId !== operationId) { attempt = { ...attempt, operationId }; - requirePrepaidAttemptSaved(attempt); + requirePrepaidAttemptSaved(attempt, originUserId); } if (attempt.order) { orderData = { order: attempt.order }; @@ -1934,6 +2042,7 @@ export default function POSPage() { }, { headers: { + ...pinnedAuthHeaders(originToken), 'Idempotency-Key': attempt.orderIdempotencyKey, ...operationHeaders(operationId), }, @@ -1941,7 +2050,15 @@ export default function POSPage() { ); orderData = data; committedOrder = data.order as Order; - requirePrepaidAttemptSaved({ ...attempt, order: data.order }); + requirePrepaidAttemptSaved({ ...attempt, order: data.order }, originUserId); + if (!isSessionCurrent()) { + // Order succeeded on the server while session changed. + // Recovery record is preserved under originUserId. Stop before generating/paying bill under new user. + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } } if (!orderData) throw new Error('Order result unavailable'); const order = orderData.order; @@ -1967,8 +2084,12 @@ export default function POSPage() { ) { try { const { data: currentOrderData } = await api.get(`/orders/${orderId}`, { - headers: operationHeaders(operationId), + headers: { + ...pinnedAuthHeaders(originToken), + ...operationHeaders(operationId), + }, }); + if (!isSessionCurrent()) return; const serverDiscount = currentOrderData.order?.discount_type && Number(currentOrderData.order.discount_value) > 0 @@ -1985,6 +2106,7 @@ export default function POSPage() { // an approval PIN may be required to reapply an uncertain discount. } } + if (!isSessionCurrent()) return; if ( !attempt.bill && !discountAlreadyApplied && @@ -2001,8 +2123,14 @@ export default function POSPage() { override_pin: discountForRequest?.override_pin, approver_id: discountForRequest?.approver_id, }, - { headers: operationHeaders(operationId) }, + { + headers: { + ...pinnedAuthHeaders(originToken), + ...operationHeaders(operationId), + }, + }, ); + if (!isSessionCurrent()) return; } if (attempt.bill) { @@ -2013,9 +2141,24 @@ export default function POSPage() { const { data: generatedBill } = await api.post( '/bills/generate', { order_id: orderId }, - { headers: operationHeaders(operationId) }, + { + headers: { + ...pinnedAuthHeaders(originToken), + ...operationHeaders(operationId), + }, + }, ); billData = generatedBill; + if (!isSessionCurrent()) { + // Bill was generated on server while session changed. Stop here. + if (generatedBill?.bill) { + savePrepaidAttempt({ ...attempt, order, bill: generatedBill.bill }, originUserId); + } + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } } if (!billData) throw new Error('Bill result unavailable'); const bill = billData.bill; @@ -2030,8 +2173,9 @@ export default function POSPage() { failureOperation = attempt.bill ? 'payment.create' : 'bill.generate'; failureStage = 'retry_state'; attempt = { ...attempt, order, bill, paymentFingerprint: exactPaymentFingerprint }; - requirePrepaidAttemptSaved(attempt); + requirePrepaidAttemptSaved(attempt, originUserId); } + if (!isSessionCurrent()) return; // Record every split in one atomic request. The persisted bill/key pair // makes a lost response safe to retry without creating a second order. @@ -2039,6 +2183,7 @@ export default function POSPage() { failureStage = 'payment_submission'; const paymentResponse = await api.post(`/bills/${bill.id}/payments`, paymentRequest, { headers: { + ...pinnedAuthHeaders(originToken), 'Idempotency-Key': attempt.paymentIdempotencyKey, ...operationHeaders(operationId), }, @@ -2049,16 +2194,27 @@ export default function POSPage() { ? paymentResponse.data.loyaltyPointsEarned : 0; + if (!isSessionCurrent()) { + savePrepaidAttempt({ ...attempt, order, bill: paidBill }, originUserId); + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } + if (paidBill.payment_status !== 'paid') { const incompleteMessage = t('paymentIncomplete', { amount: currencyFmt(Number(paidBill.balance) || 0), }); - savePrepaidAttempt({ - ...attempt, - bill: paidBill, - paymentFingerprint: '', - paymentIdempotencyKey: newIdempotencyKey(), - }); + savePrepaidAttempt( + { + ...attempt, + bill: paidBill, + paymentFingerprint: '', + paymentIdempotencyKey: newIdempotencyKey(), + }, + originUserId, + ); reportOrderFailure({ code: 'payment.incomplete', title: incompleteMessage, @@ -2076,10 +2232,12 @@ export default function POSPage() { } const completedAttempt = { ...attempt, order, bill: paidBill }; - if (!savePrepaidAttempt(completedAttempt)) { + if (!savePrepaidAttempt(completedAttempt, originUserId)) { // Payment is confirmed. Keep that fact in memory and never turn a // local retry-state write failure into advice to charge again. - prepaidAttemptRef.current = stripPrepaidDiscountPin(completedAttempt); + if (originUserId === activeUserId) { + prepaidAttemptRef.current = stripPrepaidDiscountPin(completedAttempt); + } if (operationId) void captureClientOperationFailure({ operation: 'payment.create', @@ -2120,7 +2278,7 @@ export default function POSPage() { cart.tableId, cart.heldOrderId || undefined, ); - if (!deleted) await heldOrders.fetchHeldOrders(); + if (!deleted && isSessionCurrent()) await heldOrders.fetchHeldOrders(); } catch (heldOrderError) { // The payment is complete; clearing the held-order record is cleanup. console.error('Failed to clear held order after payment', heldOrderError); @@ -2131,31 +2289,39 @@ export default function POSPage() { correlationId: operationId, }); } - reportOrderFailure({ - code: 'order.held_state.cleanup_failed', - title: t('orderPaid', { number: order.order_number }), - message: t('saleCompleteCleanupFailed'), - nextStep: t('saleCompleteCheckOrders'), - detail: 'Payment completed but its held-order record could not be refreshed', - status: null, - operation: 'payment.create', - stage: 'cleanup', - outcome: 'applied', - reasonCode: 'order.held_state.cleanup_failed', - correlationId: operationId, - operationFailure: - !(heldOrderError as { response?: unknown } | null)?.response && !!operationId - ? { - capture_point: 'fc.frontend.pos.payment.create.held_cleanup', - operation_id: operationId, - outcome: 'applied', - } - : undefined, - }); + if (isSessionCurrent()) { + reportOrderFailure({ + code: 'order.held_state.cleanup_failed', + title: t('orderPaid', { number: order.order_number }), + message: t('saleCompleteCleanupFailed'), + nextStep: t('saleCompleteCheckOrders'), + detail: 'Payment completed but its held-order record could not be refreshed', + status: null, + operation: 'payment.create', + stage: 'cleanup', + outcome: 'applied', + reasonCode: 'order.held_state.cleanup_failed', + correlationId: operationId, + operationFailure: + !(heldOrderError as { response?: unknown } | null)?.response && !!operationId + ? { + capture_point: 'fc.frontend.pos.payment.create.held_cleanup', + operation_id: operationId, + outcome: 'applied', + } + : undefined, + }); + } } } + if (!isSessionCurrent()) { + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } cart.clearCart(); - if (!clearPrepaidAttempt()) { + if (!clearPrepaidAttempt(originUserId)) { if (operationId) void captureClientOperationFailure({ operation: 'payment.create', @@ -2179,11 +2345,14 @@ export default function POSPage() { setMobileCartOpen(false); await refreshTables(); void refreshScheduledOrders(); + if (!isSessionCurrent()) return; await printKotIfEnabled(order); + if (!isSessionCurrent()) return; await printBillForTenant(paidBill, isPrepaidCheckout); } catch (error) { + if (!isSessionCurrent()) return; if (error instanceof OrderAttemptStorageError && committedOrder) { if ( failureOperation === 'payment.create' && @@ -2262,11 +2431,14 @@ export default function POSPage() { failure.outcome === 'not_applied' && failure.displayCode !== 'idempotency_conflict' ) { - savePrepaidAttempt({ - ...attempt, - paymentFingerprint: '', - paymentIdempotencyKey: newIdempotencyKey(), - }); + savePrepaidAttempt( + { + ...attempt, + paymentFingerprint: '', + paymentIdempotencyKey: newIdempotencyKey(), + }, + originUserId, + ); } } reportOrderRequestFailure( @@ -2278,7 +2450,10 @@ export default function POSPage() { ); } } finally { - setSubmitting(false); + if (isSessionCurrent() && activeSubmissionTokenRef.current === submissionToken) { + setSubmitting(false); + activeSubmissionTokenRef.current = null; + } } }; @@ -2402,14 +2577,24 @@ export default function POSPage() { return; } setSubmitting(true); + const originEpoch = sessionEpochRef.current; + const originUserId = activeUserId; + const originToken = + activeToken || (typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null); + const submissionToken = Symbol('pos-add-cart-to-order'); + activeSubmissionTokenRef.current = submissionToken; + + const isSessionCurrent = () => checkSessionCurrent(originEpoch, originUserId); + const operationId = createOperationReference(); try { + const cartFingerprint = buildCurrentCartFingerprint(); const items = cart.items.map(cartItemToOrderItem); const specialInstructions = order.special_instructions || undefined; const fingerprint = buildAppendItemsFingerprint(order.id, items, specialInstructions); const storage = getAppendAttemptStorage(); const itemAttempt = getOrCreateAppendAttempt(storage, { - userId: activeUserId || '', + userId: originUserId || '', orderId: order.id, fingerprint, createKey: newIdempotencyKey, @@ -2418,7 +2603,9 @@ export default function POSPage() { orderNumber: order.order_number, operationId: operationId || undefined, }); - addItemsAttemptRef.current = itemAttempt; + if (originUserId === activeUserId) { + addItemsAttemptRef.current = itemAttempt; + } const { data } = await api.post( `/orders/${order.id}/items`, { @@ -2427,11 +2614,19 @@ export default function POSPage() { }, { headers: { + ...pinnedAuthHeaders(originToken), 'Idempotency-Key': itemAttempt.idempotencyKey, ...operationHeaders(itemAttempt.operationId), }, }, ); + if (!isSessionCurrent()) { + // Items append succeeded on server while session changed. Stop before mutating state. + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } // A resolved response is the confirmation boundary. Keep the durable // attempt through all network errors so a lost response can replay it. const cleared = clearAppendAttempt(storage, itemAttempt); @@ -2465,10 +2660,17 @@ export default function POSPage() { await printKotIfEnabled( appendedItems.length > 0 ? { ...updatedOrder, items: appendedItems } : updatedOrder, ); + if (!isSessionCurrent()) { + if (buildCartFingerprintFromState(useCartStore.getState()) === cartFingerprint) { + useCartStore.getState().clearCart(); + } + return; + } cart.clearCart(); setCheckoutTable(null); refreshTables(); } catch (error) { + if (!isSessionCurrent()) return; if (error instanceof OrderAttemptStorageError) reportOrderStorageFailure(t('addItemsFailed'), 'order.append', operationId); else @@ -2480,11 +2682,18 @@ export default function POSPage() { operationId, ); } finally { - setSubmitting(false); + if (isSessionCurrent() && activeSubmissionTokenRef.current === submissionToken) { + setSubmitting(false); + activeSubmissionTokenRef.current = null; + } } }; const handlePaymentComplete = async () => { + const originEpoch = sessionEpochRef.current; + const originUserId = activeUserId; + const isSessionCurrent = () => checkSessionCurrent(originEpoch, originUserId); + const bill = paymentBill; // capture before clearing state setPaymentBill(null); setCheckoutOverridePin(undefined); @@ -2494,9 +2703,13 @@ export default function POSPage() { if (bill) { try { - await printBillForTenant(await fetchLatestBill(bill.id)); + const latestBill = await fetchLatestBill(bill.id); + if (!isSessionCurrent()) return; + await printBillForTenant(latestBill); } catch { - toast.error(t('receiptPrintFailed')); + if (isSessionCurrent()) { + toast.error(t('receiptPrintFailed')); + } } } }; @@ -3087,11 +3300,15 @@ export default function POSPage() { canGenerateBill={tenantCan(currentTenant, 'bills.generate')} canReadBills={tenantCan(currentTenant, 'bills.read')} onPayment={(bill, overridePin) => { + if (!checkoutTable) return; setCheckoutTable(null); setPaymentBill(bill); setCheckoutOverridePin(overridePin); }} - onAddCartToOrder={handleAddCartToOrder} + onAddCartToOrder={(table, order) => { + if (!checkoutTable) return; + handleAddCartToOrder(table, order); + }} onOrderMoved={({ sourceTable, targetTable }) => { if (cart.tableId === sourceTable.id) { cart.setTableId(targetTable.id); @@ -3134,10 +3351,19 @@ export default function POSPage() { // pay against the remainder source bill during the fetch window. setPaymentBill(null); setCheckoutOverridePin(undefined); + const originEpoch = sessionEpochRef.current; void api .get(`/bills/${departingBill.id}`) - .then(({ data }) => setPaymentBill((data?.bill as Bill) ?? departingBill)) - .catch(() => setPaymentBill(departingBill)); + .then(({ data }) => { + if (sessionEpochRef.current === originEpoch) { + setPaymentBill((data?.bill as Bill) ?? departingBill); + } + }) + .catch(() => { + if (sessionEpochRef.current === originEpoch) { + setPaymentBill(departingBill); + } + }); } else { setPaymentBill(null); setCheckoutOverridePin(undefined); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 77ce81662..84697ae8c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -107,7 +107,15 @@ const api = axios.create({ api.interceptors.request.use((config) => { if (typeof window !== 'undefined') { const token = localStorage.getItem('token'); - if (token) { + const headers = config.headers; + const existingAuth = + headers && + (('Authorization' in headers && headers.Authorization) || + ('authorization' in headers && headers.authorization) || + (typeof (headers as { get?: (name: string) => unknown }).get === 'function' + ? (headers as { get: (name: string) => unknown }).get('Authorization') + : null)); + if (token && !existingAuth) { config.headers.Authorization = `Bearer ${token}`; } } diff --git a/frontend/src/lib/cart-order-item.ts b/frontend/src/lib/cart-order-item.ts index af0e49091..33545dea7 100644 --- a/frontend/src/lib/cart-order-item.ts +++ b/frontend/src/lib/cart-order-item.ts @@ -6,8 +6,8 @@ * resolves and prices the variant from the database; only the id is sent. */ -import type { CartItem } from '@/lib/types'; -import { normalizeOverridePrice } from '@/lib/cart-price'; +import type { CartItem } from './types'; +import { normalizeOverridePrice } from './cart-price'; export interface OrderItemAddonPayload { id: string; diff --git a/frontend/src/lib/cart-price.ts b/frontend/src/lib/cart-price.ts index 45282fff6..65983c693 100644 --- a/frontend/src/lib/cart-price.ts +++ b/frontend/src/lib/cart-price.ts @@ -6,7 +6,7 @@ * counter order. Kept dependency-free so the cart store stays loadable in tests. */ -import type { ProductVariant } from '@/lib/types'; +import type { ProductVariant } from './types'; const NON_TWO_DECIMAL_COUNTRY_CURRENCIES: Record = { AF: 'AFN', diff --git a/frontend/src/lib/order-attempt.ts b/frontend/src/lib/order-attempt.ts index 89a8a9d64..7c35b7987 100644 --- a/frontend/src/lib/order-attempt.ts +++ b/frontend/src/lib/order-attempt.ts @@ -1,4 +1,9 @@ import type { AppendAttemptStorage } from './append-attempt'; +import { + cartItemToOrderItem, + type OrderItemPayload, + type OrderItemSource, +} from './cart-order-item'; /** Global key used by builds before prepaid attempts became user scoped. */ export const PREPAID_ATTEMPT_STORAGE_KEY = 'flo.prepaid.checkout.attempt'; @@ -30,6 +35,56 @@ export function getPrepaidOrderAttemptStorageKey(userId: string): string { return `${PREPAID_ATTEMPT_STORAGE_KEY}${ORDER_ATTEMPT_USER_SUFFIX}${encodeURIComponent(userId)}`; } +export interface CartStateForFingerprint { + tableId: string | null; + customerId: number | string | null; + orderType: string; + guestCount: number; + orderNotes?: string | null; + scheduledFor?: string | null; + onlinePlatform?: string | null; + externalOrderId?: string | null; + deliveryAddress?: string | null; + expectedPaymentMethod?: string | null; + expectedPaymentMethodId?: number | string | null; + deliveryNote?: string | null; + items: OrderItemSource[]; + waivedChargeIds: Set; + optedInChargeIds: Set; +} + +export function buildCartFingerprintFromState( + cartState: CartStateForFingerprint, + orderItems: OrderItemPayload[] = cartState.items.map(cartItemToOrderItem), +): string { + return JSON.stringify({ + table_id: cartState.tableId, + customer_id: cartState.customerId, + type: cartState.orderType, + guest_count: cartState.guestCount, + special_instructions: cartState.orderNotes, + scheduled_for: + cartState.orderType === 'takeaway' || cartState.orderType === 'delivery' + ? cartState.scheduledFor?.trim() || undefined + : undefined, + online_platform: cartState.orderType === 'online' ? cartState.onlinePlatform : undefined, + external_order_id: cartState.orderType === 'online' ? cartState.externalOrderId : undefined, + delivery_address: + cartState.orderType === 'delivery' ? cartState.deliveryAddress || undefined : undefined, + expected_payment_method: + cartState.orderType === 'delivery' ? cartState.expectedPaymentMethod || undefined : undefined, + expected_payment_method_id: + cartState.orderType === 'delivery' + ? (cartState.expectedPaymentMethodId ?? undefined) + : undefined, + delivery_note: + cartState.orderType === 'delivery' ? cartState.deliveryNote || undefined : undefined, + items: orderItems, + waived_charge_ids: Array.from(cartState.waivedChargeIds), + opted_in_charge_ids: Array.from(cartState.optedInChargeIds), + }); +} + function isCompletedAttempt(value: unknown): value is CompletedOrderAttempt { return ( !!value && typeof value === 'object' && (value as { completed?: unknown }).completed === true diff --git a/main/routes/bills.ts b/main/routes/bills.ts index 960d7f135..f376783b1 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -5846,7 +5846,8 @@ router.patch( if ( !liveCaller || liveCaller.is_active !== 1 || - !hasRole(liveCaller.role, ROLE_ACCESS.ownerManager) + !hasRole(liveCaller.role, ROLE_ACCESS.ownerManager) || + !hasPermission(liveCaller.id, 'payments.take') ) { throw paymentValidationError( 'Only owners and managers can correct payment methods', diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 44f3b598f..a62aafd7f 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -1752,20 +1752,31 @@ router.get( }, ); +const requireAuthenticatedStaff: RequestHandler = (req, res, next) => { + const userId = String( + (req as Request & { user?: { userId?: string; id?: string } }).user?.userId || + (req as any).user?.id || + '', + ); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required', code: 'authentication_required' }); + } + const db = getDatabase(); + const user = db.prepare('SELECT id, is_active FROM users WHERE id = ?').get(userId) as + | { id: string; is_active: number } + | undefined; + if (!user || user.is_active !== 1) { + return res.status(401).json({ error: 'Invalid or expired token', code: 'invalid_token' }); + } + next(); +}; + router.get( '/auto_lock_minutes', settingsReadRateLimit, - requireAnyPermission( - 'settings.view', - 'pos.use', - 'orders.read', - 'orders.create', - 'orders.status.update', - 'tables.view', - 'catalog.view', - 'kitchen.use', - 'support.use', - ), + requireAuthenticatedStaff, (_req: Request, res: Response) => { try { const setting = getDatabase() diff --git a/main/services/daily-invoices-export.ts b/main/services/daily-invoices-export.ts index 833fec409..c59808a10 100644 --- a/main/services/daily-invoices-export.ts +++ b/main/services/daily-invoices-export.ts @@ -30,6 +30,7 @@ import { type DailySalesExportQueryInput, } from './daily-sales-export'; import { isExcludedFromBillReceipt } from '../../shared/order-item-status'; +import { receiptChargeLines } from '../../shared/charges'; export const DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS = 366; export const MAX_DAILY_INVOICES_COUNT = 5000; @@ -118,6 +119,7 @@ export type DailyInvoiceCard = { service_charge: number; packaging_charge: number; delivery_charge: number; + charges_breakdown: string | null; tax_amount: number; tax_components: DisplayTaxComponent[]; tip_amount: number; @@ -599,6 +601,7 @@ export function buildDailyInvoicesExportDataset( delivery_charge: number; packaging_charge: number; service_charge: number; + charges_breakdown: string | null; round_off: number; total: number; paid_amount: number; @@ -632,6 +635,7 @@ export function buildDailyInvoicesExportDataset( COALESCE(b.delivery_charge, 0) AS delivery_charge, COALESCE(b.packaging_charge, 0) AS packaging_charge, COALESCE(b.service_charge, 0) AS service_charge, + b.charges_breakdown AS charges_breakdown, COALESCE(b.round_off, 0) AS round_off, COALESCE(b.total, 0) AS total, COALESCE(b.paid_amount, 0) AS paid_amount, @@ -944,6 +948,7 @@ export function buildDailyInvoicesExportDataset( service_charge: Number(bill.service_charge || 0), packaging_charge: Number(bill.packaging_charge || 0), delivery_charge: Number(bill.delivery_charge || 0), + charges_breakdown: bill.charges_breakdown ? String(bill.charges_breakdown) : null, tax_amount: Number(bill.tax_amount || 0), tax_components: taxComponents, tip_amount: tipAmount, @@ -1220,6 +1225,33 @@ export function serializeDailyInvoicesExportHtml( }) .join('\n'); + const itemisedCharges = receiptChargeLines(inv.charges_breakdown); + const itemisedIds = new Set(itemisedCharges.map((charge) => charge.id)); + const chargeLines: string[] = []; + if (inv.service_charge > 0 && !itemisedIds.has('service_charge')) { + chargeLines.push( + `
Service Charge${escapeHtml(fmtMoney(inv.service_charge))}
`, + ); + } + if (inv.packaging_charge > 0 && !itemisedIds.has('packaging_charge')) { + chargeLines.push( + `
Packaging Charge${escapeHtml(fmtMoney(inv.packaging_charge))}
`, + ); + } + if (inv.delivery_charge > 0 && !itemisedIds.has('delivery_charge')) { + chargeLines.push( + `
Delivery Charge${escapeHtml(fmtMoney(inv.delivery_charge))}
`, + ); + } + for (const charge of itemisedCharges) { + const label = (charge as any).label || charge.name || charge.id; + chargeLines.push( + `
${escapeHtml(label)}${escapeHtml(fmtMoney(charge.amount))}
`, + ); + } + const chargesHtml = + chargeLines.length > 0 ? chargeLines.join('\n ') : ''; + const taxComponentLines = inv.tax_components.length > 0 ? inv.tax_components @@ -1328,9 +1360,7 @@ export function serializeDailyInvoicesExportHtml(
Subtotal${escapeHtml(fmtMoney(inv.subtotal))}
${inv.discount_amount > 0 ? `
Discount-${escapeHtml(fmtMoney(inv.discount_amount))}
` : ''} - ${inv.service_charge > 0 ? `
Service Charge${escapeHtml(fmtMoney(inv.service_charge))}
` : ''} - ${inv.packaging_charge > 0 ? `
Packaging Charge${escapeHtml(fmtMoney(inv.packaging_charge))}
` : ''} - ${inv.delivery_charge > 0 ? `
Delivery Charge${escapeHtml(fmtMoney(inv.delivery_charge))}
` : ''} + ${chargesHtml} ${taxComponentLines} ${inv.tip_amount > 0 ? `
Tip${escapeHtml(fmtMoney(inv.tip_amount))}
` : ''} ${inv.round_off !== 0 ? `
Round-off${escapeHtml(fmtMoney(inv.round_off))}
` : ''} diff --git a/shared/whatsapp-automation.ts b/shared/whatsapp-automation.ts index d7a21ddb7..6359b2c9a 100644 --- a/shared/whatsapp-automation.ts +++ b/shared/whatsapp-automation.ts @@ -565,6 +565,7 @@ export function isAutoOptOutText(body: string): boolean { /** * Evaluates whether `currentHhMm` ('HH:MM') falls inside `[startHhMm, endHhMm)`. * Supports overnight windows (e.g. '22:00' to '09:00') and same-day windows. + * When `endHhMm` is '23:59', the window includes the final minute of the day. */ export function isTimeWithinQuietHours( currentHhMm: string, @@ -576,7 +577,7 @@ export function isTimeWithinQuietHours( const end = normalizeTimeHhMm(endHhMm, '09:00'); if (start === end) return false; if (start < end) { - return currentHhMm >= start && currentHhMm < end; + return currentHhMm >= start && (end === '23:59' || currentHhMm < end); } // Overnight window (e.g. 22:00 -> 09:00) return currentHhMm >= start || currentHhMm < end; diff --git a/tests/daily-invoices-export.test.ts b/tests/daily-invoices-export.test.ts index 8c106fd3a..f28655b23 100644 --- a/tests/daily-invoices-export.test.ts +++ b/tests/daily-invoices-export.test.ts @@ -1230,6 +1230,161 @@ async function main() { ); } console.log(' ✓ All 7 Qodo review invariants verified'); + + console.log( + '\n─── 7. Custom charges (Eco Fee) and legacy charge fallback in archived invoices (PR #1400) ───', + ); + { + const chargesDate = '2026-09-22'; + const ecoBreakdown = JSON.stringify([ + { + id: 'eco_fee', + name: 'Eco Fee', + type: 'fixed', + amount: 10.0, + calculation_basis: 'net', + waived: false, + }, + ]); + + // Bill with 10.00 custom Eco Fee (subtotal: 60.00, tax: 4.20, total: 74.20) + insertBillWithOrder({ + billNumber: 'INV-ECO-01', + orderNumber: 'ORD-ECO-01', + paidAt: `${chargesDate}T12:00:00Z`, + paymentStatus: 'paid', + subtotal: 60, + taxAmount: 4.2, + taxRate: 7, + taxTitle: 'VAT 7%', + total: 74.2, + method: 'cash', + items: [ + { + productId: 'prod-latte', + productName: 'Eco Coffee', + qty: 1, + unitPrice: 60, + subtotal: 60, + taxAmount: 4.2, + total: 64.2, + }, + ], + }); + // Attach charges_breakdown to the newly inserted order and bill + db.prepare("UPDATE orders SET charges_breakdown = ? WHERE order_number = 'ORD-ECO-01'").run( + ecoBreakdown, + ); + db.prepare("UPDATE bills SET charges_breakdown = ? WHERE bill_number = 'INV-ECO-01'").run( + ecoBreakdown, + ); + + // Legacy bill with charges_breakdown = NULL and legacy service, packaging, delivery charges + insertBillWithOrder({ + billNumber: 'INV-LEGACY-01', + orderNumber: 'ORD-LEGACY-01', + paidAt: `${chargesDate}T13:00:00Z`, + paymentStatus: 'paid', + subtotal: 50, + serviceCharge: 5.0, + taxAmount: 3.5, + taxRate: 7, + taxTitle: 'VAT 7%', + total: 63.5, + method: 'card', + items: [ + { + productId: 'prod-croissant', + productName: 'Legacy Croissant', + qty: 1, + unitPrice: 50, + subtotal: 50, + taxAmount: 3.5, + total: 53.5, + }, + ], + }); + db.prepare( + "UPDATE bills SET packaging_charge = 3.0, delivery_charge = 2.0, total = 63.5 WHERE bill_number = 'INV-LEGACY-01'", + ).run(); + db.prepare( + "UPDATE orders SET packaging_charge = 3.0, delivery_charge = 2.0, total = 63.5 WHERE order_number = 'ORD-LEGACY-01'", + ).run(); + + // Bill where orders.charges_breakdown has a custom fee but bills.charges_breakdown is NULL (Qodo Finding #3) + insertBillWithOrder({ + billNumber: 'INV-SPLIT-NULL-BREAKDOWN-01', + orderNumber: 'ORD-WITH-ORDER-FEE-01', + paidAt: `${chargesDate}T14:00:00Z`, + paymentStatus: 'paid', + subtotal: 40, + serviceCharge: 4.0, + taxAmount: 2.8, + taxRate: 7, + taxTitle: 'VAT 7%', + total: 46.8, + method: 'cash', + items: [ + { + productId: 'prod-cookie', + productName: 'Choco Cookie', + qty: 1, + unitPrice: 40, + subtotal: 40, + taxAmount: 2.8, + total: 42.8, + }, + ], + }); + const orderOnlyCustomFee = JSON.stringify([ + { + id: 'order_custom_fee', + name: 'Order Level Surcharge', + type: 'fixed', + amount: 25.0, + calculation_basis: 'net', + waived: false, + }, + ]); + db.prepare( + "UPDATE orders SET charges_breakdown = ? WHERE order_number = 'ORD-WITH-ORDER-FEE-01'", + ).run(orderOnlyCustomFee); + db.prepare( + "UPDATE bills SET charges_breakdown = NULL WHERE bill_number = 'INV-SPLIT-NULL-BREAKDOWN-01'", + ).run(); + + const res = await fetch( + `${baseUrl}/api/reports/daily-invoices/export?date=${chargesDate}&format=html`, + { headers: owner.authHeader }, + ); + assert.equal(res.status, 200, 'owner export for charges date returns 200'); + const html = await res.text(); + + // Verify custom Eco Fee renders in archived invoice + assert.ok(html.includes('Eco Fee'), 'custom charge "Eco Fee" renders in archived invoice'); + assert.ok(html.includes('10.00'), 'custom charge amount "10.00" renders in archived invoice'); + + // Verify bill with NULL charges_breakdown never adopts orders.charges_breakdown (Qodo Finding #3) + assert.ok( + !html.includes('Order Level Surcharge'), + 'bill with NULL charges_breakdown does not inherit orders.charges_breakdown', + ); + + // Verify legacy bills with charges_breakdown = NULL still render standard charge lines + assert.ok( + html.includes('Service Charge'), + 'legacy bill renders "Service Charge" when charges_breakdown is NULL', + ); + assert.ok( + html.includes('Packaging Charge'), + 'legacy bill renders "Packaging Charge" when charges_breakdown is NULL', + ); + assert.ok( + html.includes('Delivery Charge'), + 'legacy bill renders "Delivery Charge" when charges_breakdown is NULL', + ); + } + console.log(' ✓ Custom charges and legacy fallbacks verified in archived invoices'); } finally { await new Promise((resolve) => server.close(() => resolve())); closeDatabase(); diff --git a/tests/payment-method-correction.test.ts b/tests/payment-method-correction.test.ts index 3bbf6e1a6..c5a69e9d3 100644 --- a/tests/payment-method-correction.test.ts +++ b/tests/payment-method-correction.test.ts @@ -444,6 +444,59 @@ async function main() { 'rejected cashier/server/chef attempts perform zero payment_idempotency writes', ); + // Revoking payments.take while PIN verification is in flight rejects in transaction (PR #1394) + const origCompare = bcrypt.compare; + let hookExecuted = false; + bcrypt.compare = async function (...args: any[]) { + if (!hookExecuted) { + hookExecuted = true; + db.prepare(` + INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'payments.take', 'deny', ?, ?, ?) + `).run(managerId, ownerId, now(), now()); + } + return origCompare.apply(this, args); + }; + + try { + const revokedDuringPinAttempt = await api( + baseUrl, + `/api/bills/${bill1.id}/payments/0/method`, + { + method: 'PATCH', + headers: { ...managerAuth, 'Idempotency-Key': 'idem-manager-revoked-during-pin' }, + body: { + method: 'card', + transaction_id: 'TX-REVOKED-DURING-PIN', + reason: 'Manager permission revoked during PIN verification', + approver_id: managerId, + override_pin: '1234', + }, + }, + ); + assertEqualOrThrow( + revokedDuringPinAttempt.status, + 403, + 'revoking payments.take while PIN verification is in flight returns 403', + ); + assertEqualOrThrow( + revokedDuringPinAttempt.data.reason, + 'insufficient_role_permissions', + 'revoking payments.take while PIN verification is in flight returns reason = insufficient_role_permissions', + ); + const billAfterRevocation = await api(baseUrl, `/api/bills/${bill1.id}`, { + headers: ownerAuth, + }); + assertEqualOrThrow( + billAfterRevocation.data.bill.payment_details[0].method, + 'cash', + 'bill payment method remains unchanged after revoked permission rejection', + ); + } finally { + bcrypt.compare = origCompare; + db.prepare('DELETE FROM user_permission_overrides WHERE user_id = ?').run(managerId); + } + // Now test PIN validation when called by an authorized manager actor const managerWithCashierPinAndId = await api( baseUrl, diff --git a/tests/pin-quick-switch.test.ts b/tests/pin-quick-switch.test.ts index 929f5e61b..a482ef040 100644 --- a/tests/pin-quick-switch.test.ts +++ b/tests/pin-quick-switch.test.ts @@ -12,17 +12,20 @@ const originalLoad = Module._load; const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-pin-quick-switch-')); let mockApiHandler: { - get?: (url: string) => Promise; + get?: (url: string, config?: any) => Promise; post?: (url: string, body?: any, config?: any) => Promise; + patch?: (url: string, body?: any, config?: any) => Promise; } = {}; const mockPosSettingsStore = { state: { language: 'en', + billingType: 'prepaid', billTemplateSource: 'core' as 'core' | null, billLanguagePolicy: null as unknown, kotLanguagePolicy: null as unknown, autoLockMinutes: 0, + tablesRequired: false, setLanguage(language: string) { this.language = language; }, @@ -43,6 +46,134 @@ const mockPosSettingsStore = { return this.state; }, }; +const mockPosSettingsStoreFn = Object.assign( + (selector?: (s: any) => any) => + selector ? selector(mockPosSettingsStore.state) : mockPosSettingsStore.state, + mockPosSettingsStore, +); + +const realReact = frontendRequire('react'); +const hookStates: any[] = []; +const hookSetters: any[] = []; +const hookRefs: any[] = []; +const hookEffectEntries: any[] = []; +const hookPendingEffects: Array<() => void> = []; + +let hookStateIndex = 0; +let hookRefIndex = 0; +let hookEffectIndex = 0; + +const reactHooks: Record = { + ...realReact, + useSyncExternalStore: (subscribe: any, getSnapshot: () => any) => getSnapshot(), + useDebugValue: () => {}, + useState: (initial: any) => { + const idx = hookStateIndex++; + if (idx >= hookStates.length) { + hookStates.push(typeof initial === 'function' ? initial() : initial); + hookSetters.push((next: any) => { + hookStates[idx] = typeof next === 'function' ? next(hookStates[idx]) : next; + }); + } + return [hookStates[idx], hookSetters[idx]]; + }, + useRef: (initial: any) => { + const idx = hookRefIndex++; + if (idx >= hookRefs.length) { + hookRefs.push({ current: initial }); + } + return hookRefs[idx]; + }, + useEffect: (effect: any, deps: any) => { + const idx = hookEffectIndex++; + const prev = hookEffectEntries[idx]; + const changed = + !prev || + !prev.initialized || + deps === undefined || + prev.deps === undefined || + deps.length !== prev.deps.length || + deps.some((d: any, i: number) => !Object.is(d, prev.deps[i])); + if (!prev) hookEffectEntries[idx] = { deps, initialized: false }; + else prev.deps = deps; + if (changed) { + hookPendingEffects.push(() => { + if (typeof hookEffectEntries[idx].cleanup === 'function') hookEffectEntries[idx].cleanup(); + hookEffectEntries[idx].cleanup = effect(); + hookEffectEntries[idx].initialized = true; + }); + } + }, + useCallback: (fn: any) => fn, + useMemo: (fn: any) => fn(), +}; + +function findElement(node: any, type: any): any { + if (!node || typeof node !== 'object') return null; + if (node.type === type) return node; + const children = Array.isArray(node.props?.children) + ? node.props.children + : node.props?.children + ? [node.props.children] + : []; + for (const child of children) { + const found = findElement(child, type); + if (found) return found; + } + return null; +} + +function renderPOSPage(POSPage: any) { + hookStateIndex = 0; + hookRefIndex = 0; + hookEffectIndex = 0; + const res = POSPage({}); + while (hookPendingEffects.length > 0) { + const fn = hookPendingEffects.shift()!; + fn(); + } + return res; +} + +function resetPOSHooks() { + hookStates.length = 0; + hookSetters.length = 0; + hookRefs.length = 0; + hookEffectEntries.length = 0; + hookPendingEffects.length = 0; + hookStateIndex = 0; + hookRefIndex = 0; + hookEffectIndex = 0; + capturedTopbarProps = null; + capturedScheduledDrawerProps = null; +} + +let capturedCartPanelProps: any = null; +let capturedPrepaidModalProps: any = null; +let capturedTopbarProps: any = null; +let capturedScheduledDrawerProps: any = null; + +const CartPanelStub = (props: any) => { + capturedCartPanelProps = props; + return null; +}; +const PrepaidCheckoutModalStub = (props: any) => { + capturedPrepaidModalProps = props; + return null; +}; +const PosTopbarStub = (props: any) => { + capturedTopbarProps = props; + return null; +}; +const ScheduledOrdersDrawerStub = (props: any) => { + capturedScheduledDrawerProps = props; + return null; +}; +const toastStub = Object.assign(() => {}, { + success: () => {}, + error: () => {}, + custom: () => {}, +}); Module._load = function (reqName: string, parent: unknown, isMain: boolean) { if (reqName === 'electron') { @@ -54,15 +185,80 @@ Module._load = function (reqName: string, parent: unknown, isMain: boolean) { }, }; } + if (reqName === 'react') return reactHooks; + if (reqName === 'react-hot-toast') + return { __esModule: true, default: toastStub, toast: toastStub }; + if (reqName === 'lucide-react') return new Proxy({}, { get: () => () => null }); + if (reqName === 'use-intl') return { useTranslations: () => (k: string) => k }; + if (reqName === 'next/dynamic') return () => () => null; + if (reqName === '@/components/ui/sidebar') return { useSidebar: () => ({ isMobile: false }) }; + if (reqName === '@/components/ui/drawer') + return { Drawer: () => null, DrawerContent: () => null, DrawerTrigger: () => null }; + if (reqName === '@/hooks/use-confirm') + return { useConfirm: () => ({ confirm: async () => true, ConfirmDialog: () => null }) }; + if (reqName === '@/hooks/useCashSession') + return { + useCashSession: () => ({ + isShiftOpen: true, + openSessions: [], + openModalOpen: false, + setOpenModalOpen: () => {}, + closeModalOpen: false, + setCloseModalOpen: () => {}, + }), + }; + if (reqName === '@/hooks/usePosSort') + return { usePosSortPreference: () => ({ sortBy: 'name', sortDirection: 'asc' }) }; + if (reqName === '@/hooks/useCashDrawerMovements') + return { useCashDrawerMovements: () => ({ open: false, setOpen: () => {} }) }; + if (reqName === '@/hooks/usePrinter') + return { + usePrinterStore: () => ({ isConnected: false, status: 'disconnected' }), + isPrintCancelledOnTenantChangeError: () => false, + }; + if (reqName === '@/hooks/useBarcodeScanner') return { useBarcodeScanner: () => {} }; + if (reqName === '@/hooks/usePosKeyboardShortcuts') return { usePosKeyboardShortcuts: () => {} }; + if (reqName === '@/hooks/useFormatCurrency') + return { useFormatCurrency: () => (v: number) => `$${v}` }; + if (reqName === '@/hooks/useSupportTicketStatus') + return { useSupportTicketStatus: () => ({ errorCount: 0 }) }; + if (reqName === '@/lib/countries') + return { + getCurrencySymbol: () => '$', + getCountryByCode: () => ({ code: 'US', currency: 'USD' }), + }; if (reqName === '@/lib/api') { return { - get: (...args: [string]) => mockApiHandler.get?.(...args), + __esModule: true, + default: { + get: (...args: [string, any]) => mockApiHandler.get?.(...args), + post: (...args: [string, any, any]) => mockApiHandler.post?.(...args), + patch: (...args: [string, any, any]) => mockApiHandler.patch?.(...args), + }, + get: (...args: [string, any]) => mockApiHandler.get?.(...args), post: (...args: [string, any, any]) => mockApiHandler.post?.(...args), + patch: (...args: [string, any, any]) => mockApiHandler.patch?.(...args), + operationHeaders: () => ({}), + createOperationReference: () => 'op-test', + captureClientOperationFailure: () => {}, }; } if (reqName === '@/store/pos-settings') { - return { usePosSettingsStore: mockPosSettingsStore }; + return { usePosSettingsStore: mockPosSettingsStoreFn }; + } + if (reqName === '@/components/pos/PrepaidCheckoutModal') { + return { __esModule: true, default: PrepaidCheckoutModalStub }; + } + if (reqName === '@/components/pos/CartPanel') { + return { __esModule: true, default: CartPanelStub }; } + if (reqName === '@/components/pos/PosTopbar') { + return { __esModule: true, default: PosTopbarStub }; + } + if (reqName === '@/components/pos/ScheduledOrdersDrawer') { + return { __esModule: true, default: ScheduledOrdersDrawerStub }; + } + if (reqName.startsWith('@/components/')) return () => null; if (reqName === '@/lib/cart-identity') { return originalLoad.call( this, @@ -82,6 +278,18 @@ Module._load = function (reqName: string, parent: unknown, isMain: boolean) { if (reqName === 'zustand') { return originalLoad.call(this, frontendRequire.resolve('zustand'), parent, isMain); } + if (reqName.startsWith('@/')) { + try { + return originalLoad.call( + this, + path.join(process.cwd(), 'frontend/src', reqName.slice(2)), + parent, + isMain, + ); + } catch { + return () => null; + } + } return originalLoad.apply(this, arguments as any); }; @@ -816,6 +1024,51 @@ async function run() { 'Invalid auto_lock_minutes value (10) is rejected with 400', ); + // User with only reports.view / dashboard access can read auto_lock_minutes (PR #1392) + const reportsOnlyId = 'reports-only-user'; + const reportsOnlyAuth = makeToken(reportsOnlyId, 'server', 'reports@test.local'); + db.prepare(` + INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) + VALUES (?, 'Reports Staff', 'reports@test.local', ?, 'server', ?, 1, ?, ?) + `).run(reportsOnlyId, bcrypt.hashSync('Pass123!', 10), bcrypt.hashSync('8888', 10), ts, ts); + + for (const perm of [ + 'pos.use', + 'orders.read', + 'orders.create', + 'orders.status.update', + 'tables.view', + 'catalog.view', + 'kitchen.use', + 'support.use', + 'settings.view', + ]) { + db.prepare(` + INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, ?, 'deny', 'owner-1', ?, ?) + `).run(reportsOnlyId, perm, ts, ts); + } + db.prepare(` + INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, created_at, updated_at) + VALUES (?, 'reports.view', 'allow', 'owner-1', ?, ?) + `).run(reportsOnlyId, ts, ts); + + await request(app).put('/api/settings/auto_lock_minutes').set(ownerAuth).send({ value: '15' }); + + const reportsUserAutoLock = await request(app) + .get('/api/settings/auto_lock_minutes') + .set(reportsOnlyAuth); + assertEqualOrThrow( + reportsUserAutoLock.status, + 200, + 'Reports-only staff user can read /api/settings/auto_lock_minutes (200)', + ); + assertEqualOrThrow( + reportsUserAutoLock.body.setting.value, + '15', + 'Reports-only staff user receives configured non-zero idle-lock timeout (15)', + ); + // ── 7. Frontend auth + cart store lock & quick-switch behavior ── console.log( '\n7. Frontend auth & cart store: lock preserves cart, clears privileged state, ignores stale async callbacks', @@ -982,9 +1235,524 @@ async function run() { 'Stale /auth/me callback from previous session is ignored after user switch', ); + // ── 8. POS checkout session-continuation guard ── + console.log( + '\n8. POS checkout session-continuation guard: switching session invalidates in-flight checkout and preserves recovery state', + ); + + const fakeStorage = { + values: new Map(), + getItem(k: string) { + return this.values.get(k) ?? null; + }, + setItem(k: string, v: string) { + this.values.set(k, String(v)); + }, + removeItem(k: string) { + this.values.delete(k); + }, + }; + (globalThis as any).window = { + dispatchEvent: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + setTimeout, + clearTimeout, + localStorage: fakeStorage, + sessionStorage: fakeStorage, + }; + const documentCookieMap = new Map(); + (globalThis as any).document = { + fullscreenElement: null, + addEventListener: () => {}, + removeEventListener: () => {}, + get cookie() { + return Array.from(documentCookieMap.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + }, + set cookie(str: string) { + if (!str) return; + const [pair, ...directives] = str.split(';'); + const eqIdx = pair.indexOf('='); + if (eqIdx !== -1) { + const k = pair.slice(0, eqIdx).trim(); + const v = pair.slice(eqIdx + 1).trim(); + const maxAgeDirective = directives.find((d) => + d.trim().toLowerCase().startsWith('max-age='), + ); + if (maxAgeDirective && maxAgeDirective.split('=')[1].trim() === '0') { + documentCookieMap.delete(k); + } else { + documentCookieMap.set(k, v); + } + } + }, + }; + (globalThis as any).localStorage = fakeStorage; + (globalThis as any).sessionStorage = fakeStorage; + + const { getPrepaidOrderAttemptStorageKey } = require('../frontend/src/lib/order-attempt'); + const POSPage = require('../frontend/src/app/(dashboard)/pos/page').default; + + resetPOSHooks(); + capturedCartPanelProps = null; + capturedPrepaidModalProps = null; + + // 1. Sign in as owner + useAuthStore.setState({ + user: { id: 'owner-1', name: 'Alice Owner', email: 'owner@test.local', role: 'owner' }, + token: 'token-for-owner-1', + isLocked: false, + }); + useCartStore.getState().clearCart(); + useCartStore.getState().addItem( + { + id: 'prod-1', + name: 'Coffee', + price: 100, + category_id: 'cat-1', + is_available: 1, + } as any, + 1, + ); + + let tree = renderPOSPage(POSPage); + const cartPanelEl = findElement(tree, CartPanelStub); + assertOrThrow(cartPanelEl, 'CartPanel element found in render tree'); + assertOrThrow( + typeof cartPanelEl.props?.onPlaceOrder === 'function', + 'CartPanel onPlaceOrder is a function', + ); + + // Trigger place order to open prepaid checkout modal + cartPanelEl.props.onPlaceOrder(); + tree = renderPOSPage(POSPage); + const prepaidModalEl = findElement(tree, PrepaidCheckoutModalStub); + assertOrThrow(prepaidModalEl, 'PrepaidCheckoutModal element found in render tree'); + assertOrThrow( + typeof prepaidModalEl.props?.onConfirm === 'function', + 'PrepaidCheckoutModal onConfirm is a function', + ); + + const handlePrepaidCheckout = prepaidModalEl.props.onConfirm; + + let ordersDeferredResolve!: (val: any) => void; + const ordersPromise = new Promise((resolve) => { + ordersDeferredResolve = resolve; + }); + const checkoutCalls: Array<{ url: string; body?: any; headers?: any }> = []; + mockApiHandler = { + post: async (url: string, body?: any, config?: any) => { + checkoutCalls.push({ url, body, headers: config?.headers }); + if (url === '/orders') { + return ordersPromise; + } + if (url === '/bills/generate') { + return { data: { bill: { id: 'bill-1', payment_status: 'unpaid' } } }; + } + if (url.includes('/payments')) { + return { data: { bill: { id: 'bill-1', payment_status: 'paid' } } }; + } + return { data: {} }; + }, + get: async () => ({ data: {} }), + }; + + // 2. Trigger prepaid checkout (starts /orders call and holds) + const checkoutPromise = handlePrepaidCheckout([{ method: 'cash', amount: 100 }], 0, null); + + // Allow promise tick so api.post('/orders') executes + await new Promise((r) => setTimeout(r, 15)); + + // 3. While /orders is held, lock and switch to manager + useAuthStore.setState({ + user: null, + token: null, + isLocked: true, + }); + useAuthStore.setState({ + user: { id: 'mgr-1', name: 'Bob Manager', email: 'manager@test.local', role: 'manager' }, + token: 'token-for-mgr-1', + isLocked: false, + }); + useCartStore.getState().clearCart(); + useCartStore.getState().addItem( + { + id: 'prod-2', + name: 'Tea', + price: 50, + category_id: 'cat-1', + is_available: 1, + } as any, + 1, + ); + + // Re-render to propagate session switch + renderPOSPage(POSPage); + + // 4. Release /orders response + ordersDeferredResolve({ data: { order: { id: 'order-123', order_number: 'ORD-123' } } }); + await checkoutPromise; + + // 5. Assertions under manager + const billOrPayCalls = checkoutCalls.filter( + (c) => c.url === '/bills/generate' || c.url.includes('/payments'), + ); + assertEqualOrThrow( + billOrPayCalls.length, + 0, + 'No bill generation or payment calls executed under manager session', + ); + + const mgrCartItems = useCartStore.getState().items; + assertEqualOrThrow(mgrCartItems.length, 1, 'Manager cart has 1 item'); + assertEqualOrThrow(mgrCartItems[0].product.id, 'prod-2', 'Manager cart item is Tea'); + + const ownerKey = getPrepaidOrderAttemptStorageKey('owner-1'); + const ownerSavedRaw = fakeStorage.getItem(ownerKey); + assertOrThrow(ownerSavedRaw, 'Owner recovery state exists in storage under owner key'); + const ownerSaved = JSON.parse(ownerSavedRaw!); + assertEqualOrThrow( + ownerSaved?.order?.id, + 'order-123', + 'Owner recovery state preserved with created order id', + ); + + // 6. Switch back to owner-1 and verify owner recovery state is preserved + useAuthStore.setState({ + user: null, + token: null, + isLocked: true, + }); + useAuthStore.setState({ + user: { id: 'owner-1', name: 'Alice Owner', email: 'owner@test.local', role: 'owner' }, + token: 'token-for-owner-1', + isLocked: false, + }); + renderPOSPage(POSPage); + + const ownerRestored = JSON.parse(fakeStorage.getItem(ownerKey)!); + assertEqualOrThrow( + ownerRestored?.order?.id, + 'order-123', + 'Owner recovery record preserved after switching back to owner', + ); + + // ── 9. Cart isolation across user switches ── + console.log( + '\n9. Cart isolation: in-flight response resolution under !isSessionCurrent() clears untouched cart while subsequent orders can be placed normally', + ); + + // Sign in as owner again + useAuthStore.setState({ + user: { id: 'owner-1', name: 'Alice Owner', email: 'owner@test.local', role: 'owner' }, + token: 'token-for-owner-1', + isLocked: false, + }); + useCartStore.getState().clearCart(); + useCartStore.getState().addItem( + { + id: 'prod-latte', + name: 'Latte', + price: 120, + category_id: 'cat-1', + is_available: 1, + } as any, + 1, + ); + + resetPOSHooks(); + capturedCartPanelProps = null; + capturedPrepaidModalProps = null; + tree = renderPOSPage(POSPage); + + let p2CartPanel = findElement(tree, CartPanelStub); + p2CartPanel.props.onPlaceOrder(); + tree = renderPOSPage(POSPage); + const p2PrepaidModal = findElement(tree, PrepaidCheckoutModalStub); + + let p2OrdersDeferredResolve!: (val: any) => void; + const p2OrdersPromise = new Promise((resolve) => { + p2OrdersDeferredResolve = resolve; + }); + const p2Calls: Array<{ url: string; body?: any }> = []; + mockApiHandler = { + post: async (url: string, body?: any) => { + p2Calls.push({ url, body }); + if (url === '/orders') return p2OrdersPromise; + if (url === '/bills/generate') + return { data: { bill: { id: 'bill-2', payment_status: 'unpaid' } } }; + if (url.includes('/payments')) + return { data: { bill: { id: 'bill-2', payment_status: 'paid' } } }; + return { data: {} }; + }, + get: async () => ({ data: {} }), + }; + + const p2CheckoutPromise = p2PrepaidModal.props.onConfirm( + [{ method: 'cash', amount: 120 }], + 0, + null, + ); + await new Promise((r) => setTimeout(r, 15)); + + // User switches to manager while in-flight, but manager DOES NOT touch the cart + useAuthStore.setState({ + user: null, + token: null, + isLocked: true, + }); + useAuthStore.setState({ + user: { id: 'mgr-1', name: 'Bob Manager', email: 'manager@test.local', role: 'manager' }, + token: 'token-for-mgr-1', + isLocked: false, + }); + renderPOSPage(POSPage); + + // Cart before release still has Latte + assertEqualOrThrow(useCartStore.getState().items.length, 1, 'Manager starts with untouched cart'); + + // Release the in-flight order + p2OrdersDeferredResolve({ + data: { order: { id: 'order-latte-456', order_number: 'ORD-LATTE-456' } }, + }); + await p2CheckoutPromise; + + // Stale continuation must have safely cleared the matching cart so manager cannot double-charge + assertEqualOrThrow( + useCartStore.getState().items.length, + 0, + 'Stale continuation safely cleared matching committed cart after switch', + ); + + // Now, suppose manager re-creates the exact same cart and attempts checkout + useCartStore.getState().addItem( + { + id: 'prod-latte', + name: 'Latte', + price: 120, + category_id: 'cat-1', + is_available: 1, + } as any, + 1, + ); + renderPOSPage(POSPage); + tree = renderPOSPage(POSPage); + p2CartPanel = findElement(tree, CartPanelStub); + + // Trigger onPlaceOrder() under manager: subsequent order with identical items can be placed normally + p2CartPanel.props.onPlaceOrder(); + tree = renderPOSPage(POSPage); + const managerPrepaidModal = findElement(tree, PrepaidCheckoutModalStub); + assertOrThrow( + managerPrepaidModal, + 'Prepaid checkout modal opens for subsequent order with identical items', + ); + assertEqualOrThrow( + useCartStore.getState().items.length, + 1, + 'Cart preserved for checkout rather than blocked or cleared', + ); + + const { buildCartFingerprintFromState } = require('../frontend/src/lib/order-attempt'); + const baseCartForFingerprint = { + tableId: null, + customerId: null, + orderType: 'takeaway', + guestCount: 1, + orderNotes: null, + items: [ + { + product: { id: 'prod-latte', name: 'Latte', price: 120 }, + quantity: 1, + addons: [], + }, + ], + waivedChargeIds: new Set(), + optedInChargeIds: new Set(), + }; + const fpImmediate = buildCartFingerprintFromState({ + ...baseCartForFingerprint, + scheduledFor: '', + }); + const fpScheduled1 = buildCartFingerprintFromState({ + ...baseCartForFingerprint, + scheduledFor: '2026-10-30T10:00:00.000Z', + }); + const fpScheduled2 = buildCartFingerprintFromState({ + ...baseCartForFingerprint, + scheduledFor: '2026-10-30T11:00:00.000Z', + }); + assertOrThrow(fpImmediate !== fpScheduled1, 'Immediate and scheduled order fingerprints differ'); + assertOrThrow( + fpScheduled1 !== fpScheduled2, + 'Different scheduled times yield different fingerprints', + ); + + const fpDineIn1 = buildCartFingerprintFromState({ + ...baseCartForFingerprint, + orderType: 'dine_in', + tableId: 't-1', + scheduledFor: '2026-10-30T10:00:00.000Z', + }); + const fpDineIn2 = buildCartFingerprintFromState({ + ...baseCartForFingerprint, + orderType: 'dine_in', + tableId: 't-1', + scheduledFor: '', + }); + assertEqualOrThrow(fpDineIn1, fpDineIn2, 'Dine-in ignores scheduledFor in cart fingerprint'); + + useAuthStore.setState({ + user: null, + token: null, + isLocked: true, + }); + useAuthStore.setState({ + user: { id: 'owner-1', name: 'Alice Owner', email: 'owner@test.local', role: 'owner' }, + token: 'token-for-owner-1', + isLocked: false, + }); + useCartStore.getState().clearCart(); + useCartStore.getState().addItem( + { + id: 'prod-latte', + name: 'Latte', + price: 120, + category_id: 'cat-1', + is_available: 1, + } as any, + 1, + ); + tree = renderPOSPage(POSPage); + p2CartPanel = findElement(tree, CartPanelStub); + p2CartPanel.props.onPlaceOrder(); + tree = renderPOSPage(POSPage); + const recoveredModal = findElement(tree, PrepaidCheckoutModalStub); + assertOrThrow( + recoveredModal, + 'Prepaid checkout modal opens for originating user with matching attempt', + ); + assertEqualOrThrow( + useCartStore.getState().items.length, + 1, + 'Cart is preserved rather than wiped during prepaid recovery', + ); + + // ── 10. Append continuation and pendingOrder isolation across session switches ── + console.log( + '\n10. Append continuation: session switch clears pendingOrder and in-flight append continuation clears cart', + ); + + // 1. Sign in as owner and open scheduled orders drawer + useAuthStore.setState({ + user: { id: 'owner-1', name: 'Alice Owner', email: 'owner@test.local', role: 'owner' }, + token: 'token-for-owner-1', + isLocked: false, + }); + renderPOSPage(POSPage); + tree = renderPOSPage(POSPage); + const topbarEl = findElement(tree, PosTopbarStub); + assertOrThrow(topbarEl, 'PosTopbar element found in render tree'); + topbarEl.props.onShowScheduledOrders(); + tree = renderPOSPage(POSPage); + + const scheduledDrawerEl = findElement(tree, ScheduledOrdersDrawerStub); + assertOrThrow(scheduledDrawerEl, 'ScheduledOrdersDrawer element found in render tree'); + scheduledDrawerEl.props.onAddItemsToOrder({ + id: 'ord-append-1', + order_number: 'ORD-APP-001', + type: 'takeaway', + status: 'in_progress', + items: [], + }); + tree = renderPOSPage(POSPage); + p2CartPanel = findElement(tree, CartPanelStub); + + // CartPanel now has existingOrder set to the target order + assertEqualOrThrow( + p2CartPanel?.props?.existingOrder?.id, + 'ord-append-1', + 'pendingOrder is set on CartPanel before session switch', + ); + + // Add items to append + useCartStore.getState().clearCart(); + useCartStore.getState().addItem( + { + id: 'prod-latte', + name: 'Latte', + price: 120, + category_id: 'cat-1', + is_available: 1, + addons: [], + } as any, + 1, + ); + tree = renderPOSPage(POSPage); + p2CartPanel = findElement(tree, CartPanelStub); + + let appendDeferredResolve!: (val: any) => void; + const appendPromise = new Promise((resolve) => { + appendDeferredResolve = resolve; + }); + mockApiHandler = { + post: async (url: string, body?: any) => { + if (url.includes('/items')) return appendPromise; + return { data: {} }; + }, + get: async () => ({ data: {} }), + }; + + const appendCallPromise = p2CartPanel.props.onPlaceOrder(); + await new Promise((r) => setTimeout(r, 15)); + + // 2. Switch to manager while append request is in-flight + useAuthStore.setState({ + user: null, + token: null, + isLocked: true, + }); + useAuthStore.setState({ + user: { id: 'mgr-1', name: 'Bob Manager', email: 'manager@test.local', role: 'manager' }, + token: 'token-for-mgr-1', + isLocked: false, + }); + renderPOSPage(POSPage); + tree = renderPOSPage(POSPage); + p2CartPanel = findElement(tree, CartPanelStub); + + // Verify: pendingOrder was cleared upon session switch + assertEqualOrThrow( + p2CartPanel?.props?.existingOrder, + null, + 'pendingOrder is cleared when switching session', + ); + + // 3. Resolve the in-flight append call + appendDeferredResolve({ + data: { + order: { + id: 'ord-append-1', + order_number: 'ORD-APP-001', + items: [{ id: 1, product_id: 'prod-latte', quantity: 1 }], + }, + }, + }); + await appendCallPromise; + + // Stale append continuation must have safely cleared the matching cart so new session doesn't double-order + assertEqualOrThrow( + useCartStore.getState().items.length, + 0, + 'Stale append continuation safely cleared matching cart after session switch', + ); + closeDatabase(); fs.rmSync(testDir, { recursive: true, force: true }); console.log('\n✅ All PIN Quick Switch & Approval Hardening tests passed!'); + process.exit(0); } run().catch((err) => { diff --git a/tests/whatsapp-service.test.ts b/tests/whatsapp-service.test.ts index 55706a276..e386ee8b6 100644 --- a/tests/whatsapp-service.test.ts +++ b/tests/whatsapp-service.test.ts @@ -467,6 +467,13 @@ async function main(): Promise { whatsapp.isTimeWithinQuietHours('14:00', '22:00', '09:00') === false, 'isTimeWithinQuietHours handles overnight quiet-hours windows accurately', ); + assert( + whatsapp.isTimeWithinQuietHours('23:59', '00:00', '23:59') === true && + whatsapp.isTimeWithinQuietHours('00:00', '00:00', '23:59') === true && + whatsapp.isTimeWithinQuietHours('23:59', '22:00', '23:59') === true && + whatsapp.isTimeWithinQuietHours('21:59', '22:00', '23:59') === false, + 'isTimeWithinQuietHours includes 23:59 when quiet hours extend to end of day', + ); assert( whatsapp.isCurrentlyQuietHours( {