From 1226691bfa2436e7e4baccf71565358ec6af820b Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 00:01:52 -0400 Subject: [PATCH 01/22] feat(auth): PIN quick user switch and picker Allow 4-6 digit PINs for all staff roles to support tile-based PIN quick user switching on the lock/login screen while keeping all approval paths strictly restricted to active owner/manager PINs. Fixes #1338 --- .../authentication-and-authorization.md | 39 +- docs/reference/roles-and-permissions.md | 14 +- frontend/e2e/database-export-import.spec.ts | 2 +- frontend/e2e/pin-quick-switch.spec.ts | 159 ++++ frontend/src/app/(dashboard)/layout.tsx | 59 +- frontend/src/app/(dashboard)/pos/page.tsx | 13 + .../src/app/(dashboard)/settings/page.tsx | 37 +- frontend/src/app/(dashboard)/staff/page.tsx | 53 +- frontend/src/app/auth/login/page.tsx | 239 +++++- .../components/layout/MenuActionHandler.tsx | 5 + frontend/src/components/layout/Sidebar.tsx | 15 +- frontend/src/components/pos/CartPanel.tsx | 20 +- frontend/src/components/pos/PosTopbar.tsx | 14 + frontend/src/lib/api.ts | 7 + frontend/src/lib/i18n/messages/ar.json | 18 +- frontend/src/lib/i18n/messages/bn.json | 18 +- frontend/src/lib/i18n/messages/de.json | 18 +- frontend/src/lib/i18n/messages/en.json | 18 +- frontend/src/lib/i18n/messages/es.json | 18 +- frontend/src/lib/i18n/messages/fa.json | 18 +- frontend/src/lib/i18n/messages/fil.json | 18 +- frontend/src/lib/i18n/messages/fr.json | 18 +- frontend/src/lib/i18n/messages/hi.json | 18 +- frontend/src/lib/i18n/messages/id.json | 18 +- frontend/src/lib/i18n/messages/it.json | 18 +- frontend/src/lib/i18n/messages/ja.json | 18 +- frontend/src/lib/i18n/messages/ko.json | 18 +- frontend/src/lib/i18n/messages/ne.json | 18 +- frontend/src/lib/i18n/messages/nl.json | 18 +- frontend/src/lib/i18n/messages/pt.json | 18 +- frontend/src/lib/i18n/messages/ru.json | 18 +- frontend/src/lib/i18n/messages/sq.json | 18 +- frontend/src/lib/i18n/messages/th.json | 18 +- frontend/src/lib/i18n/messages/tr.json | 18 +- frontend/src/lib/i18n/messages/ur.json | 18 +- frontend/src/lib/i18n/messages/vi.json | 18 +- frontend/src/lib/i18n/messages/zh-tw.json | 18 +- frontend/src/lib/i18n/messages/zh.json | 18 +- frontend/src/store/auth.ts | 73 +- frontend/src/store/cart.ts | 33 + frontend/src/store/pos-settings.ts | 19 + frontend/src/store/session-events.ts | 22 + main/db.ts | 45 + main/routes/auth.ts | 171 +++- main/routes/bills.ts | 144 +++- main/routes/orders.ts | 40 +- main/routes/settings.ts | 44 + main/routes/staff.ts | 24 +- package.json | 3 +- tests/backup-restore-production.test.ts | 4 +- tests/pin-quick-switch.test.ts | 810 ++++++++++++++++++ tests/staff-authz.test.ts | 13 +- 52 files changed, 2335 insertions(+), 218 deletions(-) create mode 100644 frontend/e2e/pin-quick-switch.spec.ts create mode 100644 frontend/src/store/session-events.ts create mode 100644 tests/pin-quick-switch.test.ts diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index 6ddf93a5f..43d3be8b9 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -137,12 +137,15 @@ permission check inside the `withTxn` callback, after the transaction opens, usi `hasPermission(actorId, id)` against state read in the same transaction. `PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, the matching `.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override -in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting +and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check, -not a permission, because the override PIN belongs to a +not a permission, because the Staff Approval PIN belongs to a person acting in an owner/manager capacity regardless of what their individual permission grants -say. To prevent event loop starvation and transaction lock contention during CPU-intensive bcrypt -evaluations, manager PINs are verified asynchronously via `verifyPinAsync` outside the transaction, +say. Even though any staff role (`owner`, `manager`, `cashier`, `server`, `chef`) may hold a 4-6 +digit `pin_hash` for quick user switching, candidate queries and in-transaction re-validations filter +for active `owner` and `manager` accounts so a `cashier`, `server`, or `chef` PIN can never satisfy +an approval check. To prevent event loop starvation and transaction lock contention during CPU-intensive bcrypt +evaluations, Staff Approval PINs are verified asynchronously via `verifyPinAsync` outside the transaction, serialized per client IP and action key with `withPinVerificationLock` from [`main/middleware/security.ts`](../../main/middleware/security.ts) (re-checking client-IP rate limits inside the lock before executing bcrypt). Candidate filtering during pre-verification enforces the @@ -188,6 +191,34 @@ All three HTTP listeners (`:3001` Main API, `:3002` Standalone KDS, and `:3003` 3. **Reset on success (`resetSuccessfulLogin`):** A successful login from any listener immediately clears the failed-attempt count for that client IP once role and permission checks pass. 4. **Role and permission decoupling:** Non-credential rejections (such as HTTP `403` when valid credentials lack `kitchen.use` on `:3002` or `server-app.use` on `:3003`) do not count as credential failures and do not clear previously accumulated failed-login attempts. +### PIN quick user switch and terminal lock + +Any staff role (`owner`, `manager`, `cashier`, `server`, `chef`) may configure a 4-6 digit PIN +(`users.pin_hash`) in Staff management. That PIN serves as the user's sign-in PIN on shared +terminals, while approval authority stays restricted to active `owner` and `manager` accounts: + +1. **Quick-switch tile directory (`GET /api/auth/pin-users`):** Returns active users who have a + `pin_hash` set, exposing only `{ id, name, role }`. Users without a PIN are omitted from the + picker and sign in with email and password. +2. **Selected-user PIN sign-in (`POST /api/auth/pin-login`):** Accepts `{ user_id, pin, rememberMe }` + and verifies the PIN strictly against the selected `user_id` via `verifyPinAsync` without + scanning other users, so two staff members may share the same PIN. Failed attempts are tracked by + the install-wide login rate limiter per user (`${normalizedIp}:pin:${userId}`), and every attempt + is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in `'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. +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, and + charge waivers) enforce active `owner` or `manager` role eligibility both outside and inside + write transactions. A valid `cashier`, `server`, or `chef` PIN is rejected with `403` on every + approval path. + ## Password policy A password must be at least 4 characters long. There are no uppercase, lowercase, digit, or symbol diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index c44c14d02..a1b15ad94 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -86,9 +86,9 @@ An owner can flip any configurable cell for a role, or for one user, from the in | Orders | View and create orders | ✓ | ✓ | ✓ | ✓ | — | | Orders | Update order status | ✓ | ✓ | ✓ | ✓ | ✓ | | Orders | Change order customers and discounts | ✓ | ✓ | — | — | — | -| Orders | Cancel pending order items | ✓ | ✓ | — | — | — | -| Orders | Void in-progress order items (manager PIN may be required) | ✓ | ✓ | — | — | — | -| Orders | Restore cancelled order items | ✓ | ✓ | — | — | — | +| Orders | Cancel pending order items | ✓ | ✓ | — | — | — | +| Orders | Void in-progress order items (Staff Approval PIN may be required) | ✓ | ✓ | — | — | — | +| Orders | Restore cancelled order items | ✓ | ✓ | — | — | — | | Orders | Edit quantity or replace order items | ✓ | — | — | — | — | | Orders | Override order item prices | ✓ | — | — | — | — | | Orders | Create and manage held orders | ✓ | ✓ | ✓ | ✓ | — | @@ -211,6 +211,14 @@ would still hold all four of `authorization.manage`, `staff.privileged.manage`, `staff.operational.manage`, and `settings.manage`, so the last administrator is protected even while other active owners remain who have been denied one of those capabilities. +**Staff PINs and Staff Approval PINs.** Any staff role (`owner`, `manager`, `cashier`, `server`, +`chef`) can configure a 4-6 digit PIN in Staff management to appear as a quick-switch tile on the +login/lock screen (`GET /api/auth/pin-users`, `POST /api/auth/pin-login`). However, **only active +`owner` and `manager` PINs qualify as a Staff Approval PIN**: `GET /api/staff/approvers` and all +approval checks (refunds, item voids, discounts, order-status overrides, kitchen-delivery overrides, +and charge waivers) reject `cashier`, `server`, and `chef` PINs even when those users have a valid +sign-in PIN. + **Conditional surfaces.** Business type, feature settings such as KDS or WhatsApp, and account state can hide or disable a surface independently of whether the permission is granted. diff --git a/frontend/e2e/database-export-import.spec.ts b/frontend/e2e/database-export-import.spec.ts index 136a7a675..007ac2603 100644 --- a/frontend/e2e/database-export-import.spec.ts +++ b/frontend/e2e/database-export-import.spec.ts @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => { const contaminatedPayload = { app: 'FloDesktop', - schema_version: '118', + schema_version: '119', data: { settings: [], categories: [], diff --git a/frontend/e2e/pin-quick-switch.spec.ts b/frontend/e2e/pin-quick-switch.spec.ts new file mode 100644 index 000000000..ee0f397ae --- /dev/null +++ b/frontend/e2e/pin-quick-switch.spec.ts @@ -0,0 +1,159 @@ +import { test, expect } from '@playwright/test'; +import { E2E_BASE_URL as BASE } from './helpers/urls'; +import { E2E_PASSWORD, getE2eToken, setLanguage } from './helpers/test-auth'; +import { captureSettings, restoreSettings } from './helpers/kitchen-fixture'; + +const SETTINGS_TO_RESTORE = ['billing_type', 'auto_lock_minutes']; + +test.describe('PIN Quick User Switch and Lock Flow (#1338)', () => { + let ownerToken: string; + let ownerHeaders: Record; + let cashierId: string; + let settingsBefore: Map; + let createdOrderIds: string[] = []; + + test.beforeAll(async ({ request }) => { + ownerToken = getE2eToken('e2e-owner', 'owner@flo.local', 'owner'); + ownerHeaders = { + Authorization: `Bearer ${ownerToken}`, + 'Content-Type': 'application/json', + }; + settingsBefore = await captureSettings(request, SETTINGS_TO_RESTORE); + + // Ensure owner has a PIN and create/update a cashier with a PIN (4321) + const ownerPinRes = await request.put(`${BASE}/api/staff/e2e-owner`, { + headers: ownerHeaders, + data: { pin: '1111' }, + }); + expect(ownerPinRes.ok()).toBeTruthy(); + + const staffRes = await request.get(`${BASE}/api/staff?active=true`, { headers: ownerHeaders }); + expect(staffRes.ok()).toBeTruthy(); + const { staff } = await staffRes.json(); + const existingCashier = staff.find( + (member: { email?: string; id?: string }) => member.email === 'pin-cashier@flo.local', + ); + if (existingCashier) { + cashierId = existingCashier.id; + const updateRes = await request.put(`${BASE}/api/staff/${cashierId}`, { + headers: ownerHeaders, + data: { pin: '4321', is_active: 1 }, + }); + expect(updateRes.ok()).toBeTruthy(); + } else { + const createRes = await request.post(`${BASE}/api/staff`, { + headers: ownerHeaders, + data: { + name: 'QuickSwitch Cashier', + email: 'pin-cashier@flo.local', + password: E2E_PASSWORD, + role: 'cashier', + pin: '4321', + }, + }); + expect(createRes.ok()).toBeTruthy(); + const body = await createRes.json(); + cashierId = body.staff.id; + } + }); + + test.beforeEach(async ({ request }) => { + createdOrderIds = []; + await request.put(`${BASE}/api/settings/billing_type`, { + headers: ownerHeaders, + data: { value: 'prepaid' }, + }); + await request.put(`${BASE}/api/settings/auto_lock_minutes`, { + headers: ownerHeaders, + data: { value: '0' }, + }); + }); + + test.afterEach(async ({ request }) => { + if (settingsBefore) { + await restoreSettings(request, settingsBefore); + } + for (const orderId of createdOrderIds) { + try { + await request.patch(`${BASE}/api/orders/${orderId}/status`, { + headers: ownerHeaders, + data: { + status: 'cancelled', + override_pin: '1111', + approver_id: 'e2e-owner', + }, + }); + } catch { + // Ignore if already completed/cancelled + } + } + }); + + test('locks POS from topbar, quick-switches to cashier via PIN tile picker, preserves cart, and completes payment', async ({ + page, + }) => { + await page.goto(`${BASE}/auth/login`); + await page.locator('#email').fill('owner@flo.local'); + await page.locator('#password').fill(E2E_PASSWORD); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/pos/**', { timeout: 20000 }); + await setLanguage(page, 'en'); + await page.goto(`${BASE}/pos`); + + await expect(page.getByTestId('pos-product-grid')).toBeVisible(); + await page.getByTestId('pos-product-card').first().click(); + await expect(page.getByTestId('cart-item')).toHaveCount(1); + + // Click Lock in the POS topbar + await page.getByTestId('pos-lock-button').click(); + await page.waitForURL('**/auth/login**', { timeout: 15000 }); + + // Verify user tile picker is visible and shows our PIN-enabled cashier + const picker = page.getByTestId('pin-user-picker'); + await expect(picker).toBeVisible(); + const cashierTile = page.getByTestId(`pin-user-tile-${cashierId}`); + await expect(cashierTile).toBeVisible(); + await expect(cashierTile).toContainText('QuickSwitch Cashier'); + + // Tap the cashier tile and enter PIN 4321 using the keypad + await cashierTile.click(); + await expect(page.getByTestId('pin-login-form')).toBeVisible(); + await page.getByTestId('pin-pad-digit-4').click(); + await page.getByTestId('pin-pad-digit-3').click(); + await page.getByTestId('pin-pad-digit-2').click(); + await page.getByTestId('pin-pad-digit-1').click(); + await page.getByTestId('pin-login-submit').click(); + + // Verify redirect to POS, active user switched to cashier, and unfinished cart preserved + await page.waitForURL('**/pos/**', { timeout: 20000 }); + await expect(page.getByTestId('cart-item')).toHaveCount(1); + + // Complete payment as the cashier + await page.getByRole('button', { name: 'Place Order' }).click(); + await expect(page.getByRole('button', { name: 'Confirm Payment · ฿64.20' })).toBeVisible(); + await page.getByRole('button', { name: 'Cash', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Confirm Payment · ฿64.20' })).toBeEnabled(); + + const orderResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'POST' && url.pathname === '/api/orders'; + }); + const paymentResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + response.request().method() === 'POST' && + /^\/api\/bills\/[^/]+\/payments$/.test(url.pathname) + ); + }); + + await page.getByRole('button', { name: 'Confirm Payment · ฿64.20' }).click(); + + const orderPayload = await (await orderResponse).json(); + if (orderPayload?.order?.id) { + createdOrderIds.push(String(orderPayload.order.id)); + } + const paymentPayload = await (await paymentResponse).json(); + expect(paymentPayload.bill.payment_status).toBe('paid'); + await expect(page.getByText(/Order #ORD-\d+-\d+ paid!/)).toBeVisible(); + }); +}); diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 96194fb73..ce8b7ee5a 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import { usePathname } from 'next/navigation'; import AppSidebar from '@/components/layout/Sidebar'; import AuthGuard from '@/components/layout/AuthGuard'; @@ -10,8 +11,62 @@ import TitleBar from '@/components/layout/TitleBar'; import UpdateAvailableDialog from '@/components/updates/UpdateAvailableDialog'; import ProductAnalyticsDialog from '@/components/analytics/ProductAnalyticsDialog'; import { usePrinterStatusSync } from '@/hooks/usePrinter'; -import { useReceiptSettingsSync } from '@/hooks/useReceiptSettingsSync'; +import { canSyncReceiptSettings, useReceiptSettingsSync } from '@/hooks/useReceiptSettingsSync'; import { normalizePathname } from '@/lib/utils'; +import { useAuthStore } from '@/store/auth'; +import { usePosSettingsStore } from '@/store/pos-settings'; +import api from '@/lib/api'; + +function useIdleAutoLock(): void { + const token = useAuthStore((s) => s.token); + const currentTenant = useAuthStore((s) => s.currentTenant); + const autoLockMinutes = usePosSettingsStore((s) => s.autoLockMinutes); + const setAutoLockMinutes = usePosSettingsStore((s) => s.setAutoLockMinutes); + + useEffect(() => { + if (!token || !currentTenant || canSyncReceiptSettings(currentTenant)) return; + let active = true; + api + .get('/settings/auto_lock_minutes') + .then((res) => { + if (active && res.data?.setting?.value !== undefined) { + setAutoLockMinutes(res.data.setting.value); + } + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [token, currentTenant, setAutoLockMinutes]); + + useEffect(() => { + if (typeof window === 'undefined' || !token || autoLockMinutes <= 0) return; + const timeoutMs = autoLockMinutes * 60 * 1000; + let timerId: number | null = null; + + const resetTimer = () => { + if (timerId !== null) window.clearTimeout(timerId); + timerId = window.setTimeout(() => { + if (useAuthStore.getState().token) { + void useAuthStore.getState().lock(); + } + }, timeoutMs); + }; + + const events = ['pointerdown', 'keydown', 'touchstart', 'wheel', 'mousemove'] as const; + for (const ev of events) { + window.addEventListener(ev, resetTimer, { passive: true }); + } + resetTimer(); + + return () => { + if (timerId !== null) window.clearTimeout(timerId); + for (const ev of events) { + window.removeEventListener(ev, resetTimer); + } + }; + }, [token, autoLockMinutes]); +} export default function DashboardLayout({ children }: { children: React.ReactNode }) { // Normalized: the desktop build's trailingSlash:true makes usePathname() @@ -25,6 +80,8 @@ export default function DashboardLayout({ children }: { children: React.ReactNod usePrinterStatusSync(); // Hydrate receipt display settings from backend so browser print has current store details. useReceiptSettingsSync(); + // Auto-lock terminal after configured idle timeout. + useIdleAutoLock(); return ( diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index 9cc067d5f..2331f759d 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -318,6 +318,19 @@ 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); + useEffect(() => { + if (lastActiveUserIdRef.current === activeUserId) return; + lastActiveUserIdRef.current = activeUserId; + setCheckoutOverridePin(undefined); + setPaymentBill(null); + setCheckoutTable(null); + setShowPrepaidCheckout(false); + setPrepaidModalAttempt(null); + cashDrawer.setOpen(false); + shift.setOpenModalOpen(false); + shift.setCloseModalOpen(false); + }, [activeUserId, cashDrawer, shift]); const prepaidAttemptRef = useRef(null); const prepaidAttemptKeyRef = useRef(null); const postpaidAttemptRef = useRef(null); diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index 8ce4e10b7..c9b332038 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -4,7 +4,11 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuthStore } from '@/store/auth'; -import { usePosSettingsStore, type BillTemplate } from '@/store/pos-settings'; +import { + usePosSettingsStore, + normalizeAutoLockMinutesValue, + type BillTemplate, +} from '@/store/pos-settings'; import { useThemeMode, type ThemeMode } from '@/store/theme'; import { useOrdersLayoutPreference } from '@/hooks/useOrdersLayout'; import { usePosSortPreference } from '@/hooks/usePosSort'; @@ -4272,6 +4276,37 @@ export default function SettingsPage() { }} /> +
+
+

{t('autoLockLabel')}

+

{t('autoLockHint')}

+
+ +
diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 88638ca91..b36753263 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -392,7 +392,6 @@ export default function StaffPage() { setForm({ ...form, role, - pin: hasRole(role, ROLE_ACCESS.ownerManager) ? form.pin : '', station_ids: role === 'chef' ? form.station_ids : [], }); }} @@ -447,34 +446,32 @@ export default function StaffPage() {

)} - {hasRole(form.role, ROLE_ACCESS.ownerManager) && ( -
-
- - setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) }) - } - className="w-full px-3 py-2 pe-10 border rounded-lg outline-none focus:ring-2 focus:ring-brand" - maxLength={6} - pattern="[0-9]*" - inputMode="numeric" - /> - -
-

{t('pinHint')}

+
+
+ + setForm({ ...form, pin: e.target.value.replace(/\D/g, '').slice(0, 6) }) + } + className="w-full px-3 py-2 pe-10 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + maxLength={6} + pattern="[0-9]*" + inputMode="numeric" + /> +
- )} +

{t('pinHint')}

+
diff --git a/frontend/src/app/auth/login/page.tsx b/frontend/src/app/auth/login/page.tsx index 05d6f3728..7bb20c73e 100644 --- a/frontend/src/app/auth/login/page.tsx +++ b/frontend/src/app/auth/login/page.tsx @@ -18,10 +18,16 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import toast from 'react-hot-toast'; -import { Eye, EyeOff, LifeBuoy, MessageCircle, Ticket } from 'lucide-react'; +import { Delete, Eye, EyeOff, KeyRound, LifeBuoy, MessageCircle, Ticket, UserCheck } from 'lucide-react'; import { ROLE_LABEL_KEYS } from '@/lib/i18n-enums'; import { SupportTicketForm, PRE_LOGIN_SUPPORT_ENDPOINTS } from '@/components/support/SupportTicketForm'; +interface PinPickerUser { + id: string; + name: string; + role: string; +} + const WHATSAPP_COMMUNITY_URL = 'https://chat.whatsapp.com/LxHobzv6d2X81NzrVfMjzo?mode=gi_t'; // Backend enum → leaf key maps for the tenant picker. @@ -34,9 +40,10 @@ const BUSINESS_TYPE_LEAF_KEYS: Record = { function LoginContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { login, selectTenant, user, tenants, currentTenant, loadFromStorage } = useAuthStore(); + const { login, pinLogin, selectTenant, user, tenants, currentTenant, loadFromStorage } = useAuthStore(); const t = useTranslations('auth'); const tStaff = useTranslations('staff'); + const tCommon = useTranslations('common'); const tBusinessType = useTranslations('businessType'); const [email, setEmail] = useState(''); @@ -47,6 +54,10 @@ function LoginContent() { const [loginError, setLoginError] = useState(null); const [showPassword, setShowPassword] = useState(false); const [ticketDialogOpen, setTicketDialogOpen] = useState(false); + const [pinUsers, setPinUsers] = useState([]); + const [selectedPinUser, setSelectedPinUser] = useState(null); + const [pin, setPin] = useState(''); + const [pinError, setPinError] = useState(null); const tSupport = useTranslations('support'); useEffect(() => { @@ -65,6 +76,15 @@ function LoginContent() { } }) .catch(() => {}); + + fetch('/api/auth/pin-users') + .then(r => r.ok ? r.json() : null) + .then(data => { + if (Array.isArray(data?.users)) { + setPinUsers(data.users); + } + }) + .catch(() => {}); }, [router, t]); useEffect(() => { @@ -134,6 +154,47 @@ function LoginContent() { } }; + const handlePinLogin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedPinUser || pin.length < 4) return; + setLoading(true); + setPinError(null); + try { + await pinLogin(selectedPinUser.id, pin, rememberMe); + toast.success(t('signInSuccess')); + } catch (err: unknown) { + if (err instanceof StorageUnavailableError) { + setPinError(t('storageUnavailable')); + } else { + const failure = parseLoginFailure(err); + if (failure.status === 401) { + const remaining = failure.attemptsRemaining; + if (remaining === 0) { + const mins = failure.lockoutMinutes ?? 15; + setPinError(t('lockedOut', { minutes: mins })); + } else if (typeof remaining === 'number' && remaining < 4) { + setPinError( + t('invalidCredentials') + ' ' + + t('attemptsRemaining', { count: remaining }) + ); + } else { + setPinError(t('invalidCredentials')); + } + } else if (failure.status === 429) { + const mins = failure.lockoutMinutes ?? 15; + setPinError(t('lockedOut', { minutes: mins })); + } else if (failure.status === undefined) { + setPinError(t('connectionFailed')); + } else { + setPinError(t('loginFailed')); + } + } + setPin(''); + } finally { + setLoading(false); + } + }; + const shouldShowTenantSelect = !!(user && (tenants.length > 1 || searchParams.get('select_tenant') === 'true')); @@ -184,6 +245,180 @@ function LoginContent() { {t('dbErrorPrefix')} {dbError}
)} + {pinUsers.length > 0 && ( + + +
+
+

+ + {t('quickSwitchTitle')} +

+

+ {t('quickSwitchSubtitle')} +

+
+
+ {selectedPinUser ? ( +
+
+
+ + + {selectedPinUser.name} + +
+ + {ROLE_LABEL_KEYS[selectedPinUser.role] + ? tStaff(ROLE_LABEL_KEYS[selectedPinUser.role]) + : selectedPinUser.role} + +
+
+ + { + setPin(e.target.value.replace(/\D/g, '').slice(0, 6)); + if (pinError) setPinError(null); + }} + placeholder="••••" + className="text-center tracking-widest text-lg font-mono" + /> +
+
+ {(['1', '2', '3', '4', '5', '6', '7', '8', '9'] as const).map((digit) => ( + + ))} + + + +
+ {pinError && ( +

+ {pinError} +

+ )} +
+ + +
+
+ ) : ( +
+ {pinUsers.map((u) => { + const roleKey = ROLE_LABEL_KEYS[u.role]; + return ( + + ); + })} +
+ )} +
+
+ )}
diff --git a/frontend/src/components/layout/MenuActionHandler.tsx b/frontend/src/components/layout/MenuActionHandler.tsx index f54ddb0a0..6ef34167d 100644 --- a/frontend/src/components/layout/MenuActionHandler.tsx +++ b/frontend/src/components/layout/MenuActionHandler.tsx @@ -6,6 +6,7 @@ import toast from 'react-hot-toast'; import { useTranslations } from 'use-intl'; import { MasterPinPrompt } from '@/components/settings/MasterPinPrompt'; import { dispatchQuickSearchRequest } from '@/lib/quick-search'; +import { useAuthStore } from '@/store/auth'; type PendingPinAction = 'backup' | 'restore' | null; @@ -154,6 +155,10 @@ export default function MenuActionHandler() { case 'menu-master-pin': router.push('/settings?tab=data&action=master-pin'); break; + case 'lock': + case 'lock-terminal': + void useAuthStore.getState().lock(); + break; default: console.log('[Menu] Unknown action:', action); } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index dd071d0b4..66e80d66a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -23,6 +23,7 @@ import { Monitor, Boxes, Receipt, + Lock, type LucideIcon, } from 'lucide-react'; import { useTranslations, type AppConfig } from 'use-intl'; @@ -151,11 +152,12 @@ const ALL_NAV_ITEMS: NavItem[] = [ export default function AppSidebar() { const pathname = usePathname(); - const { user, currentTenant, logout } = useAuthStore(); + const { user, currentTenant, logout, lock } = useAuthStore(); const { tablesRequired, kdsEnabled, whatsappEnabled, setKdsEnabled, setWhatsappEnabled } = usePosSettingsStore(); const { isMobile, setOpenMobile } = useSidebar(); const t = useTranslations('nav'); + const tPos = useTranslations('pos'); const tCommon = useTranslations('common'); const { confirm, ConfirmDialog } = useConfirm(); const [emailNeedsAttention, setEmailNeedsAttention] = useState(false); @@ -355,6 +357,17 @@ export default function AppSidebar() { {t('support')} + { + closeMobile(); + void lock(); + }} + className="cursor-pointer flex items-center gap-2" + > + + {tPos('lockTerminal')} + { diff --git a/frontend/src/components/pos/CartPanel.tsx b/frontend/src/components/pos/CartPanel.tsx index 2dad457b8..cefe85e6c 100644 --- a/frontend/src/components/pos/CartPanel.tsx +++ b/frontend/src/components/pos/CartPanel.tsx @@ -167,12 +167,22 @@ export default function CartPanel({ }: Props) { const cart = useCartStore(); const heldOrders = useHeldOrdersStore(); - const { currentTenant } = useAuthStore(); + const { user, currentTenant } = useAuthStore(); const canOverridePrice = tenantCan(currentTenant, 'orders.item.price_override'); - const [priceOverrideItemId, setPriceOverrideItemId] = useState(null); - const priceOverrideTarget = priceOverrideItemId - ? cart.items.find((i) => i.id === priceOverrideItemId) - : undefined; + const [priceOverrideState, setPriceOverrideState] = useState<{ + userId: string | number | undefined; + itemId: string; + } | null>(null); + const priceOverrideItemId = + canOverridePrice && priceOverrideState && priceOverrideState.userId === user?.id + ? priceOverrideState.itemId + : null; + const setPriceOverrideItemId = (itemId: string | null) => + setPriceOverrideState(itemId ? { userId: user?.id, itemId } : null); + const priceOverrideTarget = + canOverridePrice && priceOverrideItemId + ? cart.items.find((i) => i.id === priceOverrideItemId) + : undefined; const priceOverrideOpen = Boolean(priceOverrideTarget); useEffect(() => { onModalOpenChange?.(priceOverrideOpen); diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index 224ff8bb3..6a9214cf0 100644 --- a/frontend/src/components/pos/PosTopbar.tsx +++ b/frontend/src/components/pos/PosTopbar.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/dropdown-menu'; import type { Product } from '@/lib/types'; import { useTranslations } from 'use-intl'; +import { useAuthStore } from '@/store/auth'; interface Props { products: Product[]; @@ -62,6 +63,7 @@ export default function PosTopbar({ const t = useTranslations('pos'); const tDashboard = useTranslations('dashboard'); const tProducts = useTranslations('products'); + const lock = useAuthStore((s) => s.lock); return ( // Identity/search first, the action buttons end-aligned next to it, and the @@ -130,6 +132,18 @@ export default function PosTopbar({ + ); @@ -254,17 +276,11 @@ function LoginContent() { {t('quickSwitchTitle')} -

- {t('quickSwitchSubtitle')} -

+

{t('quickSwitchSubtitle')}

{selectedPinUser ? ( - +
@@ -424,12 +440,30 @@ function LoginContent() {
- setEmail(e.target.value)} placeholder={t('emailPlaceholder')} dir="ltr" required /> + setEmail(e.target.value)} + placeholder={t('emailPlaceholder')} + dir="ltr" + required + />
- setPassword(e.target.value)} placeholder={t('passwordPlaceholder')} className="pe-10" required /> + setPassword(e.target.value)} + placeholder={t('passwordPlaceholder')} + className="pe-10" + required + /> diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 235f4d16f..f4c91b9c4 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -86,7 +86,11 @@ export const useAuthStore = create((set) => ({ persistSession(data.access_token, currentTenant); syncTenantLanguage(currentTenant); const failedLanguages = currentTenant - ? await syncPrintPoliciesAtBootstrap(currentTenant, usePosSettingsStore, () => operation === authOperation) + ? await syncPrintPoliciesAtBootstrap( + currentTenant, + usePosSettingsStore, + () => operation === authOperation, + ) : []; if (operation !== authOperation) return; notifySessionBoundary('login'); @@ -110,7 +114,11 @@ export const useAuthStore = create((set) => ({ persistSession(data.access_token, currentTenant); syncTenantLanguage(currentTenant); const failedLanguages = currentTenant - ? await syncPrintPoliciesAtBootstrap(currentTenant, usePosSettingsStore, () => operation === authOperation) + ? await syncPrintPoliciesAtBootstrap( + currentTenant, + usePosSettingsStore, + () => operation === authOperation, + ) : []; if (operation !== authOperation) return; notifySessionBoundary('pin-login'); @@ -153,7 +161,11 @@ export const useAuthStore = create((set) => ({ if (operation !== authOperation) return; persistSession(data.access_token, data.tenant); syncTenantLanguage(data.tenant); - const failedLanguages = await syncPrintPoliciesAtBootstrap(data.tenant, usePosSettingsStore, () => operation === authOperation); + const failedLanguages = await syncPrintPoliciesAtBootstrap( + data.tenant, + usePosSettingsStore, + () => operation === authOperation, + ); if (operation !== authOperation) return; set({ token: data.access_token, @@ -198,8 +210,10 @@ export const useAuthStore = create((set) => ({ const latestState = useAuthStore.getState(); const tenants: Tenant[] = data.tenants || []; const currentTenant = latestState.currentTenant - ? tenants.find((tenant) => tenant.id === latestState.currentTenant?.id) ?? null - : (tenants.length === 1 ? tenants[0] : null); + ? (tenants.find((tenant) => tenant.id === latestState.currentTenant?.id) ?? null) + : tenants.length === 1 + ? tenants[0] + : null; if (currentTenant) localStorage.setItem('tenant', JSON.stringify(currentTenant)); set({ user: data.user, tenants, currentTenant }); }, @@ -228,12 +242,15 @@ export const useAuthStore = create((set) => ({ } if (token) { - await api.get('/auth/me') + await api + .get('/auth/me') .then(({ data }) => { if (operation !== authOperation) return null; const tenants: Tenant[] = data.tenants; // Find the fresh version of the currently selected tenant, or default to the first one - const freshTenant = currentTenant ? tenants.find((t: Tenant) => t.id === currentTenant.id) : null; + const freshTenant = currentTenant + ? tenants.find((t: Tenant) => t.id === currentTenant.id) + : null; const resolved = freshTenant ?? (tenants.length === 1 ? tenants[0] : null); if (resolved) localStorage.setItem('tenant', JSON.stringify(resolved)); set({ @@ -244,7 +261,11 @@ export const useAuthStore = create((set) => ({ }); syncTenantLanguage(resolved); return resolved - ? syncPrintPoliciesAtBootstrap(resolved, usePosSettingsStore, () => operation === authOperation) + ? syncPrintPoliciesAtBootstrap( + resolved, + usePosSettingsStore, + () => operation === authOperation, + ) : []; }) .then((failedLanguages) => { diff --git a/main/routes/auth.ts b/main/routes/auth.ts index 6e1b2cacb..5ffba1131 100644 --- a/main/routes/auth.ts +++ b/main/routes/auth.ts @@ -344,7 +344,7 @@ router.get('/pin-users', authRateLimit({ max: 600 }), (_req: Request, res: Respo role: String(row.role), })), }); - } catch (error: any) { + } catch (error: unknown) { console.error('[Auth] PIN users error:', error); res.status(500).json({ error: 'Internal server error' }); } @@ -402,7 +402,17 @@ router.post( }); } - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any; + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as + | { + id: string; + name: string; + email: string; + role: string; + is_active: number; + pin_hash: string | null; + category_ids: string | null; + } + | undefined; let pinMatches = false; if (user && user.is_active === 1 && user.pin_hash) { try { @@ -469,7 +479,7 @@ router.post( tenant, tenants: [tenant], }); - } catch (error: any) { + } catch (error: unknown) { console.error('[Auth] PIN login error:', error); console.error('[API] Internal error:', error); res.status(500).json({ error: 'Internal server error' }); diff --git a/main/routes/bills.ts b/main/routes/bills.ts index 31b33b579..689ae3805 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -5205,7 +5205,7 @@ router.patch( return res.status(400).json({ error: 'Conflicting approver identifiers' }); } const explicitApproverId = suppliedApproverIds[0]; - const callerRole = String((req as any).user?.role || ''); + const callerRole = String((req as Request & { user?: { role?: string } }).user?.role || ''); const requiresChargeApproval = override_pin !== undefined || (waivedRequested === true && !hasRole(callerRole, ROLE_ACCESS.ownerManager)); diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 6e9806f55..703ef1d17 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -198,7 +198,8 @@ function normalizeAutoLockMinutes(value: unknown): string | null { if (value === 0 || value === '0' || value === 'off' || value === false || value === '') { return '0'; } - const str = typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''; + const str = + typeof value === 'number' ? String(value) : typeof value === 'string' ? value.trim() : ''; return VALID_AUTO_LOCK_MINUTES.has(str) ? str : null; } @@ -1746,28 +1747,24 @@ router.get( }, ); -router.get( - '/auto_lock_minutes', - settingsReadRateLimit, - (_req: Request, res: Response) => { - try { - const setting = getDatabase() - .prepare('SELECT * FROM settings WHERE key = ?') - .get('auto_lock_minutes'); - if (setting) return res.json({ setting }); - return res.json({ - setting: { - key: 'auto_lock_minutes', - value: OPTIONAL_SETTING_DEFAULTS.auto_lock_minutes, - updated_at: null, - }, - }); - } catch (error) { - console.error('[API] Internal error:', error); - return res.status(500).json({ error: 'Internal server error' }); - } - }, -); +router.get('/auto_lock_minutes', settingsReadRateLimit, (_req: Request, res: Response) => { + try { + const setting = getDatabase() + .prepare('SELECT * FROM settings WHERE key = ?') + .get('auto_lock_minutes'); + if (setting) return res.json({ setting }); + return res.json({ + setting: { + key: 'auto_lock_minutes', + value: OPTIONAL_SETTING_DEFAULTS.auto_lock_minutes, + updated_at: null, + }, + }); + } catch (error) { + console.error('[API] Internal error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +}); router.get('/offline-mode', requirePermission('settings.view'), (_req: Request, res: Response) => { res.json({ offline_mode: isOfflineMode() }); diff --git a/tests/pin-quick-switch.test.ts b/tests/pin-quick-switch.test.ts index ea2e526a3..0a986aa61 100644 --- a/tests/pin-quick-switch.test.ts +++ b/tests/pin-quick-switch.test.ts @@ -177,7 +177,11 @@ async function run() { role: 'cashier', pin: '2468', // Same PIN as owner-1! }); - assertEqualOrThrow(createCashierRes.status, 201, 'Manager can create cashier with a 4-6 digit PIN'); + assertEqualOrThrow( + createCashierRes.status, + 201, + 'Manager can create cashier with a 4-6 digit PIN', + ); const cashierId = createCashierRes.body.staff.id as string; assertEqualOrThrow(createCashierRes.body.staff.has_pin, 1, 'Created cashier has_pin is 1'); @@ -230,7 +234,11 @@ async function run() { .post('/api/auth/pin-login') .send({ user_id: cashierId, pin: '2468' }); assertEqualOrThrow(cashierPinLogin.status, 200, 'Cashier signs in with same PIN 2468'); - assertEqualOrThrow(cashierPinLogin.body.user.id, cashierId, 'Cashier PIN login returns cashierId'); + assertEqualOrThrow( + cashierPinLogin.body.user.id, + cashierId, + 'Cashier PIN login returns cashierId', + ); assertEqualOrThrow( cashierPinLogin.body.user.role, 'cashier', @@ -625,30 +633,24 @@ async function run() { // (h) Refund with cashier PIN -> 403 resetBillPinRateLimit(); - const refundCashierPin = await request(app) - .post('/api/refunds') - .set(cashierAuth) - .send({ - bill_id: billId, - amount: 20, - reason: 'Customer complaint', - method: 'card', - approver_id: cashierId, - override_pin: '7777', - }); + const refundCashierPin = await request(app).post('/api/refunds').set(cashierAuth).send({ + bill_id: billId, + amount: 20, + reason: 'Customer complaint', + method: 'card', + approver_id: cashierId, + override_pin: '7777', + }); assertEqualOrThrow(refundCashierPin.status, 403, 'Refund rejects cashier PIN with 403'); - const refundValidOwnerPin = await request(app) - .post('/api/refunds') - .set(cashierAuth) - .send({ - bill_id: billId, - amount: 20, - reason: 'Customer complaint', - method: 'card', - approver_id: 'owner-1', - override_pin: '2468', - }); + const refundValidOwnerPin = await request(app).post('/api/refunds').set(cashierAuth).send({ + bill_id: billId, + amount: 20, + reason: 'Customer complaint', + method: 'card', + approver_id: 'owner-1', + override_pin: '2468', + }); assertEqualOrThrow( refundValidOwnerPin.status, 201, @@ -660,7 +662,11 @@ async function run() { const defaultAutoLock = await request(app) .get('/api/settings/auto_lock_minutes') .set(cashierAuth); - assertEqualOrThrow(defaultAutoLock.status, 200, 'Cashier can read /api/settings/auto_lock_minutes'); + assertEqualOrThrow( + defaultAutoLock.status, + 200, + 'Cashier can read /api/settings/auto_lock_minutes', + ); assertEqualOrThrow( defaultAutoLock.body.setting.value, '0', @@ -672,11 +678,7 @@ async function run() { .put('/api/settings/auto_lock_minutes') .set(ownerAuth) .send({ value: validMins }); - assertEqualOrThrow( - saveAutoLock.status, - 200, - `Owner can set auto_lock_minutes to ${validMins}`, - ); + assertEqualOrThrow(saveAutoLock.status, 200, `Owner can set auto_lock_minutes to ${validMins}`); assertEqualOrThrow( saveAutoLock.body.setting.value, validMins, @@ -695,7 +697,9 @@ async function run() { ); // ── 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'); + console.log( + '\n7. Frontend auth & cart store: lock preserves cart, clears privileged state, ignores stale async callbacks', + ); (globalThis as any).window = { dispatchEvent: () => {}, }; @@ -738,7 +742,10 @@ async function run() { id: 1, business_name: 'Test Cafe', role: body.user_id === 'owner-1' ? 'owner' : 'cashier', - permission_ids: body.user_id === 'owner-1' ? ['pos.use', 'orders.item.price_override'] : ['pos.use'], + permission_ids: + body.user_id === 'owner-1' + ? ['pos.use', 'orders.item.price_override'] + : ['pos.use'], }, ], }, @@ -755,7 +762,11 @@ async function run() { // Sign in as owner via pinLogin await useAuthStore.getState().pinLogin('owner-1', '2468'); assertEqualOrThrow(useAuthStore.getState().user?.id, 'owner-1', 'Store signed in as owner-1'); - assertEqualOrThrow(useAuthStore.getState().isLocked, false, 'Store isLocked is false after pinLogin'); + assertEqualOrThrow( + useAuthStore.getState().isLocked, + false, + 'Store isLocked is false after pinLogin', + ); // Add item to cart, apply owner price override, and waive a charge const sampleProduct = { diff --git a/tests/staff-authz.test.ts b/tests/staff-authz.test.ts index eb036a066..77905db9e 100644 --- a/tests/staff-authz.test.ts +++ b/tests/staff-authz.test.ts @@ -72,8 +72,7 @@ async function main() { seedUser(db, 'server-target-145', 'server', ''); seedUser(db, 'chef-target-145', 'chef', ''); db.prepare(`INSERT INTO kitchen_stations (id, name, is_active, created_at, updated_at) - VALUES ('station-145', 'Main Kitchen', 1, ?, ?)` - ).run(now(), now()); + VALUES ('station-145', 'Main Kitchen', 1, ?, ?)`).run(now(), now()); // The authorization router is mounted so the precondition below is set up // through a real write rather than by poking the override tables directly. @@ -84,12 +83,14 @@ async function main() { console.log('\n── Manager boundaries ─────────────────────────────────────────'); let result = await request(app).put('/api/staff/owner-145').set(managerAuth).send({ - name: 'Compromised owner', password: 'ChangedPass1', + name: 'Compromised owner', + password: 'ChangedPass1', }); assertEqual(result.status, 403, 'manager cannot edit owner details or password'); result = await request(app).put('/api/staff/manager-target-145').set(managerAuth).send({ - name: 'Compromised manager', password: 'ChangedPass1', + name: 'Compromised manager', + password: 'ChangedPass1', }); assertEqual(result.status, 403, 'manager cannot edit another manager details or password'); @@ -103,22 +104,37 @@ async function main() { db.prepare('UPDATE users SET is_active = 1 WHERE id = ?').run(target); } - result = await request(app).put('/api/staff/cashier-target-145').set(managerAuth).send({ is_active: false }); + result = await request(app) + .put('/api/staff/cashier-target-145') + .set(managerAuth) + .send({ is_active: false }); assertEqual(result.status, 400, 'PUT cannot change is_active'); for (const role of ['owner', 'manager']) { - result = await request(app).post('/api/staff').set(managerAuth).send({ - name: `Forbidden ${role}`, email: `forbidden-${role}@test.local`, password: 'StrongPass1', role, - }); + result = await request(app) + .post('/api/staff') + .set(managerAuth) + .send({ + name: `Forbidden ${role}`, + email: `forbidden-${role}@test.local`, + password: 'StrongPass1', + role, + }); assertEqual(result.status, 403, `manager cannot create ${role}`); } console.log('\n── Manager operational-staff access ───────────────────────────'); const managerCreated: Record = {}; for (const role of ['cashier', 'server', 'chef']) { - result = await request(app).post('/api/staff').set(managerAuth).send({ - name: `Managed ${role}`, email: `managed-${role}@test.local`, password: 'StrongPass1', role, - }); + result = await request(app) + .post('/api/staff') + .set(managerAuth) + .send({ + name: `Managed ${role}`, + email: `managed-${role}@test.local`, + password: 'StrongPass1', + role, + }); assertEqual(result.status, 201, `manager can create ${role}`); managerCreated[role] = result.body.staff?.id; assert(!('pin_hash' in result.body.staff), `create ${role} response does not expose pin_hash`); @@ -127,85 +143,186 @@ async function main() { } } - result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Station Chef', email: 'station-chef@test.local', password: 'StrongPass1', role: 'chef', station_ids: ['station-145'], - }); + result = await request(app) + .post('/api/staff') + .set(managerAuth) + .send({ + name: 'Station Chef', + email: 'station-chef@test.local', + password: 'StrongPass1', + role: 'chef', + station_ids: ['station-145'], + }); assertEqual(result.status, 201, 'chef can be optionally assigned to a station during creation'); - assertEqual(result.body.staff.station_ids[0], 'station-145', 'created chef response includes the station assignment'); - const stationChef = db.prepare('SELECT station_assignments_configured FROM users WHERE id = ?').get(result.body.staff.id) as any; - assertEqual(stationChef.station_assignments_configured, 1, 'station-scoped chef is marked as explicitly configured'); - const stationLink = db.prepare('SELECT station_id FROM station_users WHERE user_id = ?').get(result.body.staff.id) as any; + assertEqual( + result.body.staff.station_ids[0], + 'station-145', + 'created chef response includes the station assignment', + ); + const stationChef = db + .prepare('SELECT station_assignments_configured FROM users WHERE id = ?') + .get(result.body.staff.id) as any; + assertEqual( + stationChef.station_assignments_configured, + 1, + 'station-scoped chef is marked as explicitly configured', + ); + const stationLink = db + .prepare('SELECT station_id FROM station_users WHERE user_id = ?') + .get(result.body.staff.id) as any; assertEqual(stationLink.station_id, 'station-145', 'chef station assignment is persisted'); result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Unassigned Chef', email: 'unassigned-chef@test.local', password: 'StrongPass1', role: 'chef', station_ids: [], + name: 'Unassigned Chef', + email: 'unassigned-chef@test.local', + password: 'StrongPass1', + role: 'chef', + station_ids: [], }); assertEqual(result.status, 201, 'chef creation does not require a station'); - const unassignedChef = db.prepare('SELECT station_assignments_configured FROM users WHERE id = ?').get(result.body.staff.id) as any; - assertEqual(unassignedChef.station_assignments_configured, 0, 'unassigned chef remains unrestricted by station configuration'); + const unassignedChef = db + .prepare('SELECT station_assignments_configured FROM users WHERE id = ?') + .get(result.body.staff.id) as any; + assertEqual( + unassignedChef.station_assignments_configured, + 0, + 'unassigned chef remains unrestricted by station configuration', + ); - result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Invalid Station Cashier', email: 'station-cashier@test.local', password: 'StrongPass1', role: 'cashier', station_ids: ['station-145'], - }); + result = await request(app) + .post('/api/staff') + .set(managerAuth) + .send({ + name: 'Invalid Station Cashier', + email: 'station-cashier@test.local', + password: 'StrongPass1', + role: 'cashier', + station_ids: ['station-145'], + }); assertEqual(result.status, 400, 'non-chef accounts cannot receive kitchen station assignments'); for (const role of ['cashier', 'server', 'chef']) { - result = await request(app).put(`/api/staff/${managerCreated[role]}`).set(managerAuth).send({ - name: `Updated ${role}`, role, - }); + result = await request(app) + .put(`/api/staff/${managerCreated[role]}`) + .set(managerAuth) + .send({ + name: `Updated ${role}`, + role, + }); assertEqual(result.status, 200, `manager can edit ${role}`); } result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Missing email server', password: 'StrongPass1', role: 'server', + name: 'Missing email server', + password: 'StrongPass1', + role: 'server', }); assertEqual(result.status, 400, 'staff creation requires email'); - assertEqual(result.body.error, 'name, email, password, and role are required', 'missing email returns a clear validation error'); + assertEqual( + result.body.error, + 'name, email, password, and role are required', + 'missing email returns a clear validation error', + ); result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Invalid email server', email: 'not-an-email', password: 'StrongPass1', role: 'server', + name: 'Invalid email server', + email: 'not-an-email', + password: 'StrongPass1', + role: 'server', }); assertEqual(result.status, 400, 'staff creation rejects invalid email'); - assertEqual(result.body.error, 'Enter a valid email address', 'invalid email returns a clear validation error'); + assertEqual( + result.body.error, + 'Enter a valid email address', + 'invalid email returns a clear validation error', + ); result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Normalized email server', email: ' Mixed.Server@Test.Local ', password: 'StrongPass1', role: 'server', + name: 'Normalized email server', + email: ' Mixed.Server@Test.Local ', + password: 'StrongPass1', + role: 'server', }); assertEqual(result.status, 201, 'staff creation trims and normalizes required email'); - assertEqual(result.body.staff.email, 'mixed.server@test.local', 'created staff email is stored normalized'); + assertEqual( + result.body.staff.email, + 'mixed.server@test.local', + 'created staff email is stored normalized', + ); - result = await request(app).put(`/api/staff/${managerCreated.server}`).set(managerAuth).send({ email: ' ' }); + result = await request(app) + .put(`/api/staff/${managerCreated.server}`) + .set(managerAuth) + .send({ email: ' ' }); assertEqual(result.status, 400, 'staff update rejects blank email'); - assertEqual(result.body.error, 'email is required', 'blank email update returns a clear validation error'); + assertEqual( + result.body.error, + 'email is required', + 'blank email update returns a clear validation error', + ); result = await request(app).post('/api/staff').set(managerAuth).send({ - name: 'Pinned cashier', email: 'pinned-cashier@test.local', password: 'StrongPass1', role: 'cashier', pin: '1234', + name: 'Pinned cashier', + email: 'pinned-cashier@test.local', + password: 'StrongPass1', + role: 'cashier', + pin: '1234', }); assertEqual(result.status, 201, 'operational roles accept a valid 4-6 digit PIN'); - assertEqual(result.body.staff.has_pin, 1, 'operational staff created with PIN report has_pin = 1'); + assertEqual( + result.body.staff.has_pin, + 1, + 'operational staff created with PIN report has_pin = 1', + ); console.log('\n── PIN policy ─────────────────────────────────────────────────'); for (const pin of ['abcd', '123', '1234567']) { - result = await request(app).post('/api/staff').set(ownerAuth).send({ - name: `Bad PIN ${pin}`, email: `bad-pin-${pin}@test.local`, password: 'StrongPass1', role: 'manager', pin, - }); + result = await request(app) + .post('/api/staff') + .set(ownerAuth) + .send({ + name: `Bad PIN ${pin}`, + email: `bad-pin-${pin}@test.local`, + password: 'StrongPass1', + role: 'manager', + pin, + }); assertEqual(result.status, 400, `PIN ${pin} is rejected unless it is 4-6 numeric digits`); } - result = await request(app).put('/api/staff/manager-target-145').set(ownerAuth).send({ role: 'server' }); + result = await request(app) + .put('/api/staff/manager-target-145') + .set(ownerAuth) + .send({ role: 'server' }); assertEqual(result.status, 200, 'owner can demote a manager to server'); - assertEqual(result.body.staff.has_pin, 1, 'demoting to an operational role preserves sign-in PIN'); - const demoted = db.prepare('SELECT pin_hash FROM users WHERE id = ?').get('manager-target-145') as any; - assert(Boolean(demoted.pin_hash), 'demoting to an operational role preserves pin_hash for quick switch'); + assertEqual( + result.body.staff.has_pin, + 1, + 'demoting to an operational role preserves sign-in PIN', + ); + const demoted = db + .prepare('SELECT pin_hash FROM users WHERE id = ?') + .get('manager-target-145') as any; + assert( + Boolean(demoted.pin_hash), + 'demoting to an operational role preserves pin_hash for quick switch', + ); const approversAfterDemotion = await request(app).get('/api/staff/approvers').set(ownerAuth); assertEqual( - approversAfterDemotion.body.approvers.some((a: any) => a.id === 'manager-target-145' || a.email === 'pinned-cashier@test.local'), + approversAfterDemotion.body.approvers.some( + (a: any) => a.id === 'manager-target-145' || a.email === 'pinned-cashier@test.local', + ), false, 'operational roles with a PIN are excluded from /api/staff/approvers', ); - const originalPinHash = (db.prepare('SELECT pin_hash FROM users WHERE id = ?').get('manager-145') as any).pin_hash; - result = await request(app).put('/api/staff/manager-145').set(ownerAuth).send({ name: 'Manager PIN preserved' }); + const originalPinHash = ( + db.prepare('SELECT pin_hash FROM users WHERE id = ?').get('manager-145') as any + ).pin_hash; + result = await request(app) + .put('/api/staff/manager-145') + .set(ownerAuth) + .send({ name: 'Manager PIN preserved' }); assertEqual(result.status, 200, 'owner can update a manager without supplying a PIN'); assertEqual(result.body.staff.has_pin, 1, 'omitting PIN preserves has_pin'); const preserved = db.prepare('SELECT pin_hash FROM users WHERE id = ?').get('manager-145') as any; @@ -217,7 +334,11 @@ async function main() { result = await request(app).put('/api/staff/owner-145').set(ownerAuth).send({ role: 'cashier' }); assertEqual(result.status, 400, 'cannot demote the last active owner'); const lastOwner = db.prepare('SELECT role FROM users WHERE id = ?').get('owner-145') as any; - assertEqual(lastOwner.role, 'owner', 'last active owner keeps the owner role after a rejected demotion'); + assertEqual( + lastOwner.role, + 'owner', + 'last active owner keeps the owner role after a rejected demotion', + ); const secondOwnerAuth = seedUser(db, 'owner-145-second', 'owner'); result = await request(app).put('/api/staff/owner-145').set(ownerAuth).send({ role: 'cashier' }); @@ -225,7 +346,11 @@ async function main() { console.log('\n── Owner full access ───────────────────────────────────────────'); result = await request(app).post('/api/staff').set(secondOwnerAuth).send({ - name: 'Owner-created manager', email: 'owner-created-manager@test.local', password: 'StrongPass1', role: 'manager', pin: '9876', + name: 'Owner-created manager', + email: 'owner-created-manager@test.local', + password: 'StrongPass1', + role: 'manager', + pin: '9876', }); assertEqual(result.status, 201, 'owner can create a manager with a valid PIN'); assertEqual(result.body.staff.has_pin, 1, 'staff responses expose has_pin for configured PINs'); @@ -242,15 +367,20 @@ async function main() { // The rest of this suite seeded owners of its own. The scenario below is only // meaningful while admin-owner-a and admin-owner-b are the active owners. - result = await request(app).put('/api/staff/owner-145-second').set(adminOwnerA).send({ role: 'manager' }); - assertEqual(result.status, 200, 'precondition: the suite\'s other owner is demoted first'); + result = await request(app) + .put('/api/staff/owner-145-second') + .set(adminOwnerA) + .send({ role: 'manager' }); + assertEqual(result.status, 200, "precondition: the suite's other owner is demoted first"); // Denying staff.operational.manage is legal while another active owner still // reaches administration, and it is exactly the state that used to make the // protected staff.privileged.manage gate inert: every staff mutation was // gated on the outer, configurable permission only. The save costs A their // own administrative access, so it carries the owner PIN seedUser stored. - const ownerAPayload = await request(app).get('/api/permissions/users/admin-owner-a').set(adminOwnerA); + const ownerAPayload = await request(app) + .get('/api/permissions/users/admin-owner-a') + .set(adminOwnerA); const stripOperational = await request(app) .put('/api/permissions/users/admin-owner-a') .set(adminOwnerA) @@ -262,39 +392,81 @@ async function main() { ], override_pin: '1234', }); - assertEqual(stripOperational.status, 200, 'an owner may self-limit while a second active owner still administers'); + assertEqual( + stripOperational.status, + 200, + 'an owner may self-limit while a second active owner still administers', + ); - result = await request(app).put('/api/staff/admin-target').set(adminOwnerA).send({ name: 'Renamed through privileged gate' }); - assertEqual(result.status, 200, 'staff.privileged.manage still admits the staff update once staff.operational.manage is denied'); + result = await request(app) + .put('/api/staff/admin-target') + .set(adminOwnerA) + .send({ name: 'Renamed through privileged gate' }); + assertEqual( + result.status, + 200, + 'staff.privileged.manage still admits the staff update once staff.operational.manage is denied', + ); // Both directions matter. The operational half must stay refused, because // canModifyTargetStaff refuses the same account on edit, deactivate and // reactivate of a cashier; admitting creation here would be incoherent. result = await request(app).post('/api/staff').set(adminOwnerA).send({ - name: 'Cashier by limited owner', email: 'limited-owner-cashier@test.local', password: 'StrongPass1', role: 'cashier', + name: 'Cashier by limited owner', + email: 'limited-owner-cashier@test.local', + password: 'StrongPass1', + role: 'cashier', }); - assertEqual(result.status, 403, 'staff.privileged.manage alone must not create operational staff'); - assertEqual(result.body.error, 'This account cannot create operational staff accounts', - 'the refused operational creation says why'); + assertEqual( + result.status, + 403, + 'staff.privileged.manage alone must not create operational staff', + ); + assertEqual( + result.body.error, + 'This account cannot create operational staff accounts', + 'the refused operational creation says why', + ); result = await request(app).post('/api/staff').set(adminOwnerA).send({ - name: 'Owner created by limited owner', email: 'limited-owner-created@test.local', password: 'StrongPass1', role: 'owner', + name: 'Owner created by limited owner', + email: 'limited-owner-created@test.local', + password: 'StrongPass1', + role: 'owner', }); assertEqual(result.status, 201, 'staff.privileged.manage still admits privileged staff creation'); const limitedOwnerId = result.body.staff?.id; result = await request(app).post('/api/staff/admin-target/deactivate').set(adminOwnerA); - assertEqual(result.status, 200, 'staff.privileged.manage still admits deactivation of a privileged account'); + assertEqual( + result.status, + 200, + 'staff.privileged.manage still admits deactivation of a privileged account', + ); result = await request(app).post('/api/staff/admin-target/reactivate').set(adminOwnerA); - assertEqual(result.status, 200, 'staff.privileged.manage still admits reactivation of a privileged account'); + assertEqual( + result.status, + 200, + 'staff.privileged.manage still admits reactivation of a privileged account', + ); - result = await request(app).put(`/api/staff/${limitedOwnerId}`).set(adminOwnerB).send({ role: 'manager' }); + result = await request(app) + .put(`/api/staff/${limitedOwnerId}`) + .set(adminOwnerB) + .send({ role: 'manager' }); assertEqual(result.status, 200, 'precondition: the owner created above is demoted again'); // Owner A now holds neither configurable administrative permission, so A is // not an administrator. Removing the other owner would leave the store with // no administrator at all, which the last-owner count check does not see // because there are still two active owners. - result = await request(app).put('/api/staff/admin-owner-b').set(adminOwnerA).send({ role: 'manager' }); + result = await request(app) + .put('/api/staff/admin-owner-b') + .set(adminOwnerA) + .send({ role: 'manager' }); assertEqual(result.status, 400, 'demoting the last remaining administrator is rejected'); - assertEqual(result.body.code, 'administration_unreachable', 'the rejected demotion carries a stable code'); + assertEqual( + result.body.code, + 'administration_unreachable', + 'the rejected demotion carries a stable code', + ); assertEqual( (db.prepare('SELECT role FROM users WHERE id = ?').get('admin-owner-b') as any).role, 'owner', @@ -303,7 +475,11 @@ async function main() { result = await request(app).post('/api/staff/admin-owner-b/deactivate').set(adminOwnerA); assertEqual(result.status, 400, 'deactivating the last remaining administrator is rejected'); - assertEqual(result.body.code, 'administration_unreachable', 'the rejected deactivation carries a stable code'); + assertEqual( + result.body.code, + 'administration_unreachable', + 'the rejected deactivation carries a stable code', + ); assertEqual( (db.prepare('SELECT is_active FROM users WHERE id = ?').get('admin-owner-b') as any).is_active, 1, @@ -312,7 +488,11 @@ async function main() { // The same writes from an owner who still reaches administration are allowed. result = await request(app).post('/api/staff/admin-owner-a/deactivate').set(adminOwnerB); - assertEqual(result.status, 200, 'an owner who still reaches administration may deactivate the other owner'); + assertEqual( + result.status, + 200, + 'an owner who still reaches administration may deactivate the other owner', + ); const results = getResults(); console.log(`\nResults: ${results.passed}/${results.total} passed`); if (results.failed > 0) { @@ -328,7 +508,9 @@ main() console.log('\nStaff authorization tests passed'); }) .catch((error) => { - try { closeDatabase(); } catch { } + try { + closeDatabase(); + } catch {} Module._load = originalLoad; fs.rmSync(testDir, { recursive: true, force: true }); console.error(error); From eb852bbaf0deaa3c189d8a9a20b22a95b61993d1 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 01:20:10 -0400 Subject: [PATCH 04/22] fix(auth): address PIN quick switch review items - Use non-blocking per-request rate limit on POST /api/auth/pin-login while preserving per-user lockout - Serialize per-user PIN verification with withPinVerificationLock and audit missing credential requests - Re-read bill/order state and compute charge breakdowns inside the write transaction on PATCH /api/bills/:id/charges - Guard GET /api/settings/auto_lock_minutes with requireAnyPermission across staff roles - Pass explicit Bearer token to POST /auth/logout before clearing storage on lock/logout - Exclude autoLockMinutes from localStorage persistence, reset on session changes, and guard stale save rollbacks - Update kitchenDeliveryOverrideInvalid across all 24 locales to use Staff Approval PIN wording --- frontend/src/app/(dashboard)/layout.tsx | 9 +- .../src/app/(dashboard)/settings/page.tsx | 6 +- frontend/src/lib/i18n/messages/ar.json | 2 +- frontend/src/lib/i18n/messages/bn.json | 2 +- frontend/src/lib/i18n/messages/de.json | 2 +- frontend/src/lib/i18n/messages/en.json | 2 +- frontend/src/lib/i18n/messages/es.json | 2 +- frontend/src/lib/i18n/messages/fa.json | 2 +- frontend/src/lib/i18n/messages/fil.json | 2 +- frontend/src/lib/i18n/messages/fr.json | 2 +- frontend/src/lib/i18n/messages/hi.json | 2 +- frontend/src/lib/i18n/messages/id.json | 2 +- frontend/src/lib/i18n/messages/it.json | 2 +- frontend/src/lib/i18n/messages/ja.json | 2 +- frontend/src/lib/i18n/messages/ko.json | 2 +- frontend/src/lib/i18n/messages/ne.json | 2 +- frontend/src/lib/i18n/messages/nl.json | 2 +- frontend/src/lib/i18n/messages/pt.json | 2 +- frontend/src/lib/i18n/messages/ru.json | 2 +- frontend/src/lib/i18n/messages/sq.json | 2 +- frontend/src/lib/i18n/messages/th.json | 2 +- frontend/src/lib/i18n/messages/tr.json | 2 +- frontend/src/lib/i18n/messages/ur.json | 2 +- frontend/src/lib/i18n/messages/vi.json | 2 +- frontend/src/lib/i18n/messages/zh-tw.json | 2 +- frontend/src/lib/i18n/messages/zh.json | 2 +- frontend/src/store/auth.ts | 32 ++- frontend/src/store/pos-settings.ts | 7 +- main/middleware/security.ts | 3 +- main/routes/auth.ts | 203 +++++++++++------- main/routes/bills.ts | 154 +++++++++---- main/routes/settings.ts | 51 +++-- tests/pin-quick-switch.test.ts | 81 ++++++- 33 files changed, 407 insertions(+), 187 deletions(-) diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index ce8b7ee5a..d20a89a3e 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -11,7 +11,7 @@ import TitleBar from '@/components/layout/TitleBar'; import UpdateAvailableDialog from '@/components/updates/UpdateAvailableDialog'; import ProductAnalyticsDialog from '@/components/analytics/ProductAnalyticsDialog'; import { usePrinterStatusSync } from '@/hooks/usePrinter'; -import { canSyncReceiptSettings, useReceiptSettingsSync } from '@/hooks/useReceiptSettingsSync'; +import { useReceiptSettingsSync } from '@/hooks/useReceiptSettingsSync'; import { normalizePathname } from '@/lib/utils'; import { useAuthStore } from '@/store/auth'; import { usePosSettingsStore } from '@/store/pos-settings'; @@ -19,12 +19,13 @@ import api from '@/lib/api'; function useIdleAutoLock(): void { const token = useAuthStore((s) => s.token); - const currentTenant = useAuthStore((s) => s.currentTenant); + const tenantId = useAuthStore((s) => s.currentTenant?.id ?? null); const autoLockMinutes = usePosSettingsStore((s) => s.autoLockMinutes); const setAutoLockMinutes = usePosSettingsStore((s) => s.setAutoLockMinutes); useEffect(() => { - if (!token || !currentTenant || canSyncReceiptSettings(currentTenant)) return; + setAutoLockMinutes(0); + if (!token || tenantId === null) return; let active = true; api .get('/settings/auto_lock_minutes') @@ -37,7 +38,7 @@ function useIdleAutoLock(): void { return () => { active = false; }; - }, [token, currentTenant, setAutoLockMinutes]); + }, [token, tenantId, setAutoLockMinutes]); useEffect(() => { if (typeof window === 'undefined' || !token || autoLockMinutes <= 0) return; diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index c9b332038..cdd4172f1 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -491,6 +491,7 @@ export default function SettingsPage() { const [savingAllSettings, setSavingAllSettings] = useState(false); const [saveAllHydrationRun, setSaveAllHydrationRun] = useState(0); const savingAllSettingsInFlight = useRef(false); + const autoLockSaveGenRef = useRef(0); const themeMode = useThemeMode((s) => s.mode); const { layout: ordersLayout, save: saveOrdersLayout } = useOrdersLayoutPreference(); @@ -4289,11 +4290,14 @@ export default function SettingsPage() { onChange={(e) => { const prev = posSettings.autoLockMinutes; const next = normalizeAutoLockMinutesValue(e.target.value); + const saveGen = ++autoLockSaveGenRef.current; posSettings.setAutoLockMinutes(next); void api .put('/settings/auto_lock_minutes', { value: String(next) }) .catch(() => { - posSettings.setAutoLockMinutes(prev); + if (autoLockSaveGenRef.current === saveGen) { + posSettings.setAutoLockMinutes(prev); + } toast.error(t('saveFailed')); }); }} diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 1b290e960..36b2846c8 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# صنفًا ما زال قيد التحضير في المطبخ} other {# صنفًا ما زال قيد التحضير في المطبخ}}", "kitchenDeliveryOverridePrompt": "لم تُسلَّم أصناف المطبخ بعد. أدخل رقم التعريف الشخصي (PIN) الخاص بالموظفين للسماح بالفوترة.", "kitchenDeliveryOverrideSuccess": "تم قبول تجاوز المدير للأصناف غير المسلّمة.", - "kitchenDeliveryOverrideInvalid": "الرقم السري للمدير غير صحيح. حاول مرة أخرى.", + "kitchenDeliveryOverrideInvalid": "رقم التعريف الشخصي (PIN) الخاص بالموظفين غير صحيح. حاول مرة أخرى.", "waiveCharge": "إعفاء", "applyCharge": "تطبيق", "addCharge": "إضافة", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index ab79c9944..2a6d95f51 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {রান্নাঘরে এখনও #টি আইটেম প্রস্তুত হচ্ছে} other {রান্নাঘরে এখনও #টি আইটেম প্রস্তুত হচ্ছে}}", "kitchenDeliveryOverridePrompt": "রান্নাঘরের আইটেম এখনও পরিবেশন করা হয়নি। বিল অনুমোদন করতে স্টাফ অনুমোদন পিন দিন।", "kitchenDeliveryOverrideSuccess": "পরিবেশন না হওয়া আইটেমের জন্য ম্যানেজারের অনুমোদন গ্রহণ করা হয়েছে।", - "kitchenDeliveryOverrideInvalid": "ম্যানেজারের PIN সঠিক নয়। আবার চেষ্টা করুন।", + "kitchenDeliveryOverrideInvalid": "স্টাফ অনুমোদন পিন সঠিক নয়। আবার চেষ্টা করুন।", "waiveCharge": "মওকুফ", "applyCharge": "প্রয়োগ", "addCharge": "যোগ করুন", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index b60d6801d..f3b77daf2 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# Artikel werden noch in der Küche zubereitet} other {# Artikel werden noch in der Küche zubereitet}}", "kitchenDeliveryOverridePrompt": "Küchenartikel wurden noch nicht serviert. Freigabe-PIN des Personals eingeben, um die Abrechnung freizugeben.", "kitchenDeliveryOverrideSuccess": "Freigabe durch die Leitung für noch nicht servierte Artikel akzeptiert.", - "kitchenDeliveryOverrideInvalid": "Ungültige Manager-PIN. Bitte erneut versuchen.", + "kitchenDeliveryOverrideInvalid": "Ungültige Freigabe-PIN des Personals. Bitte erneut versuchen.", "waiveCharge": "Erlassen", "applyCharge": "Anwenden", "addCharge": "Hinzufügen", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 1e4d3e13e..93b805a98 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -1129,7 +1129,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# item still being prepared in the kitchen} other {# items still being prepared in the kitchen}}", "kitchenDeliveryOverridePrompt": "Kitchen items are not yet delivered. Enter Staff Approval PIN to authorize billing.", "kitchenDeliveryOverrideSuccess": "Manager override accepted for undelivered items.", - "kitchenDeliveryOverrideInvalid": "Invalid Manager PIN. Please try again.", + "kitchenDeliveryOverrideInvalid": "Invalid Staff Approval PIN. Please try again.", "waiveCharge": "Waive", "applyCharge": "Apply", "addCharge": "Add", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index a29d99186..287051d93 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Hay # artículo(s) aún en preparación en la cocina} other {Hay # artículo(s) aún en preparación en la cocina}}", "kitchenDeliveryOverridePrompt": "Los artículos de cocina aún no se han entregado. Introduce el PIN de aprobación del personal para autorizar el cobro.", "kitchenDeliveryOverrideSuccess": "Se aceptó la autorización del gerente para los artículos sin entregar.", - "kitchenDeliveryOverrideInvalid": "El PIN de gerente no es válido. Inténtalo de nuevo.", + "kitchenDeliveryOverrideInvalid": "El PIN de aprobación del personal no es válido. Inténtalo de nuevo.", "waiveCharge": "Eximir", "applyCharge": "Aplicar", "addCharge": "Añadir", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index b0cf5f4ae..93b5881e2 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# مورد هنوز در آشپزخانه در حال آماده‌سازی است} other {# مورد هنوز در آشپزخانه در حال آماده‌سازی است}}", "kitchenDeliveryOverridePrompt": "اقلام آشپزخانه هنوز تحویل نشده‌اند. برای تأیید صورتحساب، پین تأیید کارکنان را وارد کنید.", "kitchenDeliveryOverrideSuccess": "تأیید مدیر برای اقلام تحویل‌نشده پذیرفته شد.", - "kitchenDeliveryOverrideInvalid": "رمز مدیر نامعتبر است. دوباره تلاش کنید.", + "kitchenDeliveryOverrideInvalid": "پین تأیید کارکنان نامعتبر است. دوباره تلاش کنید.", "waiveCharge": "بخشش", "applyCharge": "اعمال", "addCharge": "افزودن", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 2adff0301..3d7c53de7 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# item pa ang inihahanda sa kusina} other {# item pa ang inihahanda sa kusina}}", "kitchenDeliveryOverridePrompt": "Hindi pa naihahatid ang mga item mula sa kusina. Ilagay ang PIN ng pag-apruba ng staff para pahintulutan ang paniningil.", "kitchenDeliveryOverrideSuccess": "Tinanggap ang pahintulot ng manager para sa mga hindi pa naihahatid na item.", - "kitchenDeliveryOverrideInvalid": "Mali ang PIN ng manager. Subukan muli.", + "kitchenDeliveryOverrideInvalid": "Mali ang PIN ng pag-apruba ng staff. Subukan muli.", "waiveCharge": "I-exempt", "applyCharge": "Ilapat", "addCharge": "Idagdag", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index c3c19928c..c22e99042 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# article(s) sont encore en préparation en cuisine} other {# article(s) sont encore en préparation en cuisine}}", "kitchenDeliveryOverridePrompt": "Les articles de cuisine ne sont pas encore servis. Saisissez le code PIN d’approbation du personnel pour autoriser le règlement.", "kitchenDeliveryOverrideSuccess": "Dérogation du responsable acceptée pour les articles non servis.", - "kitchenDeliveryOverrideInvalid": "Code PIN du responsable incorrect. Réessayez.", + "kitchenDeliveryOverrideInvalid": "Code PIN d’approbation du personnel incorrect. Réessayez.", "waiveCharge": "Exonérer", "applyCharge": "Appliquer", "addCharge": "Ajouter", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index ade91b489..0a56df872 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {रसोई में अभी # आइटम तैयार हो रहे हैं} other {रसोई में अभी # आइटम तैयार हो रहे हैं}}", "kitchenDeliveryOverridePrompt": "रसोई के आइटम अभी परोसे नहीं गए हैं। बिल की अनुमति देने के लिए कर्मचारी अनुमोदन पिन दर्ज करें।", "kitchenDeliveryOverrideSuccess": "अभी तक न परोसे गए आइटम के लिए मैनेजर की अनुमति स्वीकार की गई।", - "kitchenDeliveryOverrideInvalid": "मैनेजर PIN गलत है। फिर से कोशिश करें।", + "kitchenDeliveryOverrideInvalid": "कर्मचारी अनुमोदन पिन गलत है। फिर से कोशिश करें।", "waiveCharge": "माफ़ करें", "applyCharge": "लागू करें", "addCharge": "जोड़ें", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index 7ae63af6a..328498eef 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Masih ada # item yang disiapkan di dapur} other {Masih ada # item yang disiapkan di dapur}}", "kitchenDeliveryOverridePrompt": "Item dapur belum diantar. Masukkan PIN Persetujuan Staf untuk mengizinkan penagihan.", "kitchenDeliveryOverrideSuccess": "Persetujuan manajer untuk item yang belum diantar diterima.", - "kitchenDeliveryOverrideInvalid": "PIN manajer tidak valid. Silakan coba lagi.", + "kitchenDeliveryOverrideInvalid": "PIN Persetujuan Staf tidak valid. Silakan coba lagi.", "waiveCharge": "Bebaskan", "applyCharge": "Terapkan", "addCharge": "Tambah", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index f6d39883e..366d9c436 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Ci sono ancora # articoli in preparazione in cucina} other {Ci sono ancora # articoli in preparazione in cucina}}", "kitchenDeliveryOverridePrompt": "Gli articoli della cucina non sono ancora stati consegnati. Inserisci il PIN di approvazione del personale per autorizzare il pagamento.", "kitchenDeliveryOverrideSuccess": "Autorizzazione del responsabile accettata per gli articoli non consegnati.", - "kitchenDeliveryOverrideInvalid": "PIN del responsabile non valido. Riprova.", + "kitchenDeliveryOverrideInvalid": "PIN di approvazione del personale non valido. Riprova.", "waiveCharge": "Esentare", "applyCharge": "Applica", "addCharge": "Aggiungi", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index 2411790ba..eb29ac612 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {キッチンで準備中の商品が # 件あります} other {キッチンで準備中の商品が # 件あります}}", "kitchenDeliveryOverridePrompt": "キッチンの商品はまだ提供されていません。会計を承認するにはスタッフ承認PINを入力してください。", "kitchenDeliveryOverrideSuccess": "未提供の商品に対するマネージャーの承認を受け付けました。", - "kitchenDeliveryOverrideInvalid": "マネージャー PIN が正しくありません。もう一度お試しください。", + "kitchenDeliveryOverrideInvalid": "スタッフ承認PINが正しくありません。もう一度お試しください。", "waiveCharge": "免除", "applyCharge": "適用", "addCharge": "追加", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index ec557f669..66ef2467d 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {주방에서 아직 준비 중인 항목이 #개 있습니다} other {주방에서 아직 준비 중인 항목이 #개 있습니다}}", "kitchenDeliveryOverridePrompt": "주방 음식이 아직 제공되지 않았습니다. 결제를 승인하려면 직원 승인 PIN을 입력하세요.", "kitchenDeliveryOverrideSuccess": "미제공 항목에 대한 관리자 승인이 처리되었습니다.", - "kitchenDeliveryOverrideInvalid": "관리자 PIN이 올바르지 않습니다. 다시 시도하세요.", + "kitchenDeliveryOverrideInvalid": "직원 승인 PIN이 올바르지 않습니다. 다시 시도하세요.", "waiveCharge": "면제", "applyCharge": "적용", "addCharge": "추가", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index a93a08745..82c900f58 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {भान्सामा अझै # परिकार तयार हुँदैछन्} other {भान्सामा अझै # परिकार तयार हुँदैछन्}}", "kitchenDeliveryOverridePrompt": "भान्साका परिकार अझै पुगेका छैनन्। बिल स्वीकृत गर्न कर्मचारी स्वीकृति पिन हाल्नुहोस्।", "kitchenDeliveryOverrideSuccess": "नपुगेका परिकारका लागि म्यानेजरको स्वीकृति स्वीकार गरियो।", - "kitchenDeliveryOverrideInvalid": "म्यानेजरको PIN मिलेन। फेरि प्रयास गर्नुहोस्।", + "kitchenDeliveryOverrideInvalid": "कर्मचारी स्वीकृति पिन मिलेन। फेरि प्रयास गर्नुहोस्।", "waiveCharge": "माफ", "applyCharge": "लागू", "addCharge": "थप्नुहोस्", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index a7a3e1e7b..9c7d2ed10 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Er worden nog # items in de keuken bereid} other {Er worden nog # items in de keuken bereid}}", "kitchenDeliveryOverridePrompt": "Keukenitems zijn nog niet bezorgd. Voer de Goedkeurings-PIN van medewerker in om afrekenen toe te staan.", "kitchenDeliveryOverrideSuccess": "Managergoedkeuring voor niet-bezorgde items is geaccepteerd.", - "kitchenDeliveryOverrideInvalid": "Ongeldige manager-pincode. Probeer het opnieuw.", + "kitchenDeliveryOverrideInvalid": "Ongeldige Goedkeurings-PIN van medewerker. Probeer het opnieuw.", "waiveCharge": "Vrijstellen", "applyCharge": "Toepassen", "addCharge": "Toevoegen", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index ff3413dbd..427623a9a 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Ainda há # item(ns) sendo preparado(s) na cozinha} other {Ainda há # item(ns) sendo preparado(s) na cozinha}}", "kitchenDeliveryOverridePrompt": "Os itens da cozinha ainda não foram entregues. Insira o PIN de aprovação da equipe para autorizar a cobrança.", "kitchenDeliveryOverrideSuccess": "Autorização do gerente aceita para os itens não entregues.", - "kitchenDeliveryOverrideInvalid": "PIN do gerente inválido. Tente novamente.", + "kitchenDeliveryOverrideInvalid": "PIN de aprovação da equipe inválido. Tente novamente.", "waiveCharge": "Isentar", "applyCharge": "Aplicar", "addCharge": "Adicionar", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index b18b18574..0780fa7ed 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {На кухне ещё готовятся блюда: #} other {На кухне ещё готовятся блюда: #}}", "kitchenDeliveryOverridePrompt": "Блюда с кухни ещё не поданы. Введите ПИН-код утверждения сотрудниками, чтобы разрешить оплату.", "kitchenDeliveryOverrideSuccess": "Разрешение менеджера на оплату неподанных блюд принято.", - "kitchenDeliveryOverrideInvalid": "Неверный PIN-код менеджера. Попробуйте ещё раз.", + "kitchenDeliveryOverrideInvalid": "Неверный ПИН-код утверждения сотрудниками. Попробуйте ещё раз.", "waiveCharge": "Списать", "applyCharge": "Применить", "addCharge": "Добавить", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index 78ffa28fb..73c502ca9 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {# artikuj po përgatiten ende në kuzhinë} other {# artikuj po përgatiten ende në kuzhinë}}", "kitchenDeliveryOverridePrompt": "Artikujt e kuzhinës nuk janë dorëzuar ende. Fut PIN-i i miratimit të stafit për të autorizuar faturimin.", "kitchenDeliveryOverrideSuccess": "U pranua miratimi i menaxherit për artikujt e padërguar.", - "kitchenDeliveryOverrideInvalid": "PIN-i i menaxherit është i pasaktë. Provo sërish.", + "kitchenDeliveryOverrideInvalid": "PIN-i i miratimit të stafit është i pasaktë. Provo sërish.", "waiveCharge": "Përjashto", "applyCharge": "Apliko", "addCharge": "Shto", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index b77d53a5f..5f87297e6 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {ยังมี # รายการที่กำลังเตรียมอยู่ในครัว} other {ยังมี # รายการที่กำลังเตรียมอยู่ในครัว}}", "kitchenDeliveryOverridePrompt": "รายการจากครัวยังไม่ได้ส่งมอบ โปรดใส่ PIN อนุมัติพนักงานเพื่ออนุมัติการชำระเงิน", "kitchenDeliveryOverrideSuccess": "ยอมรับการอนุมัติจากผู้จัดการสำหรับรายการที่ยังไม่ส่งมอบแล้ว", - "kitchenDeliveryOverrideInvalid": "PIN ผู้จัดการไม่ถูกต้อง โปรดลองอีกครั้ง", + "kitchenDeliveryOverrideInvalid": "PIN อนุมัติพนักงานไม่ถูกต้อง โปรดลองอีกครั้ง", "waiveCharge": "ยกเว้น", "applyCharge": "ใช้งาน", "addCharge": "เพิ่ม", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 818242006..b158e296b 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Mutfakta hâlâ hazırlanan # ürün var} other {Mutfakta hâlâ hazırlanan # ürün var}}", "kitchenDeliveryOverridePrompt": "Mutfak ürünleri henüz teslim edilmedi. Ödemeye izin vermek için Personel Onay PIN'i girin.", "kitchenDeliveryOverrideSuccess": "Teslim edilmemiş ürünler için yönetici onayı kabul edildi.", - "kitchenDeliveryOverrideInvalid": "Yönetici PIN kodu geçersiz. Tekrar deneyin.", + "kitchenDeliveryOverrideInvalid": "Personel Onay PIN'i geçersiz. Tekrar deneyin.", "waiveCharge": "Muaf tut", "applyCharge": "Uygula", "addCharge": "Ekle", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index 2b80ca35a..09ac7bee2 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {باورچی خانے میں ابھی # آئٹمز تیار ہو رہی ہیں} other {باورچی خانے میں ابھی # آئٹمز تیار ہو رہی ہیں}}", "kitchenDeliveryOverridePrompt": "باورچی خانے کے آئٹمز ابھی پیش نہیں کیے گئے۔ بل کی اجازت دینے کے لیے اسٹاف اپروول PIN درج کریں۔", "kitchenDeliveryOverrideSuccess": "نہ پہنچائے گئے آئٹمز کے لیے مینیجر کی اجازت قبول کر لی گئی۔", - "kitchenDeliveryOverrideInvalid": "مینیجر کا PIN درست نہیں۔ دوبارہ کوشش کریں۔", + "kitchenDeliveryOverrideInvalid": "اسٹاف اپروول PIN درست نہیں۔ دوبارہ کوشش کریں۔", "waiveCharge": "معاف", "applyCharge": "لاگو کریں", "addCharge": "شامل کریں", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index cd4510bec..cd2bc2cb1 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {Còn # món đang được chuẩn bị trong bếp} other {Còn # món đang được chuẩn bị trong bếp}}", "kitchenDeliveryOverridePrompt": "Món ăn từ bếp chưa được giao. Nhập Mã PIN phê duyệt để cho phép thanh toán.", "kitchenDeliveryOverrideSuccess": "Đã chấp nhận quyền duyệt của quản lý cho các món chưa giao.", - "kitchenDeliveryOverrideInvalid": "Mã PIN quản lý không hợp lệ. Vui lòng thử lại.", + "kitchenDeliveryOverrideInvalid": "Mã PIN phê duyệt không hợp lệ. Vui lòng thử lại.", "waiveCharge": "Miễn", "applyCharge": "Áp dụng", "addCharge": "Thêm", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 6abd8b2f2..5754ad749 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {廚房仍有 # 道餐點正在準備} other {廚房仍有 # 道餐點正在準備}}", "kitchenDeliveryOverridePrompt": "廚房餐點尚未送達。請輸入員工批准 PIN 以授權結帳。", "kitchenDeliveryOverrideSuccess": "已接受經理對未送達餐點的授權。", - "kitchenDeliveryOverrideInvalid": "經理 PIN 無效,請再試一次。", + "kitchenDeliveryOverrideInvalid": "員工批准 PIN 無效,請再試一次。", "waiveCharge": "免除", "applyCharge": "套用", "addCharge": "新增", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index b7e16b35b..b52688e5e 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -1092,7 +1092,7 @@ "kitchenItemsPendingWarning": "{count, plural, one {厨房仍有 # 道菜正在准备} other {厨房仍有 # 道菜正在准备}}", "kitchenDeliveryOverridePrompt": "厨房菜品尚未送达。请输入员工批准 PIN 以授权结账。", "kitchenDeliveryOverrideSuccess": "已接受经理对未送达菜品的授权。", - "kitchenDeliveryOverrideInvalid": "经理 PIN 无效,请重试。", + "kitchenDeliveryOverrideInvalid": "员工批准 PIN 无效,请重试。", "waiveCharge": "免除", "applyCharge": "应用", "addCharge": "添加", diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index f4c91b9c4..5e4ed7891 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -18,7 +18,17 @@ function syncTenantLanguage(t: Tenant | null | undefined) { } function clearBillTemplateProvenance(): void { - usePosSettingsStore.getState().setBillTemplateSource?.(null); + const settings = usePosSettingsStore.getState(); + settings.setBillTemplateSource?.(null); + settings.setAutoLockMinutes?.(0); +} + +function readStoredToken(): string | null { + try { + return typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; + } catch { + return null; + } } interface AuthState { @@ -67,7 +77,7 @@ function persistSession(token: string, tenant: Tenant | null): void { } } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create((set, get) => ({ user: null, token: null, tenants: [], @@ -136,7 +146,14 @@ export const useAuthStore = create((set) => ({ ++authOperation; clearBillTemplateProvenance(); notifySessionBoundary('lock'); - const logoutPromise = api.post('/auth/logout').catch(() => {}); + const tokenToRevoke = get().token ?? readStoredToken(); + const logoutPromise = api + .post( + '/auth/logout', + null, + tokenToRevoke ? { headers: { Authorization: `Bearer ${tokenToRevoke}` } } : undefined, + ) + .catch(() => {}); try { localStorage.removeItem('token'); localStorage.removeItem('tenant'); @@ -178,7 +195,14 @@ export const useAuthStore = create((set) => ({ ++authOperation; clearBillTemplateProvenance(); notifySessionBoundary('logout'); - api.post('/auth/logout').catch(() => {}); + const tokenToRevoke = get().token ?? readStoredToken(); + api + .post( + '/auth/logout', + null, + tokenToRevoke ? { headers: { Authorization: `Bearer ${tokenToRevoke}` } } : undefined, + ) + .catch(() => {}); localStorage.removeItem('token'); localStorage.removeItem('tenant'); set({ diff --git a/frontend/src/store/pos-settings.ts b/frontend/src/store/pos-settings.ts index b2afedee6..77265e53e 100644 --- a/frontend/src/store/pos-settings.ts +++ b/frontend/src/store/pos-settings.ts @@ -354,12 +354,15 @@ export const usePosSettingsStore = create()( }), { name: 'pos-settings', - // Exclude backend-synced fields (WhatsApp, print policies) from persistence. + // Exclude backend-synced fields (WhatsApp, print policies, auto-lock) from persistence. partialize: (s) => Object.fromEntries( Object.entries(s).filter( ([k]) => - k !== 'whatsappEnabled' && k !== 'billLanguagePolicy' && k !== 'kotLanguagePolicy', + k !== 'whatsappEnabled' && + k !== 'billLanguagePolicy' && + k !== 'kotLanguagePolicy' && + k !== 'autoLockMinutes', ), ) as PosSettingsState, // Migrates legacy store keys (GSTIN rename, removed A4/A5 paper sizes). diff --git a/main/middleware/security.ts b/main/middleware/security.ts index 1bbf69565..2e8ab7607 100644 --- a/main/middleware/security.ts +++ b/main/middleware/security.ts @@ -118,13 +118,14 @@ export function resetSharedLoginAuthRateLimiter(): void { } /** Stricter rate limiter for authentication endpoints; private/LAN IPs are not exempt. */ -export function authRateLimit(options: { max?: number } = {}) { +export function authRateLimit(options: { max?: number; skipSuccessfulRequests?: boolean } = {}) { const envMax = process.env.FLO_AUTH_RATE_LIMIT_MAX ? parseInt(process.env.FLO_AUTH_RATE_LIMIT_MAX, 10) : undefined; return expressRateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes limit: options.max ?? (Number.isFinite(envMax) ? envMax : 10), + skipSuccessfulRequests: options.skipSuccessfulRequests ?? false, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many authentication attempts. Please try again later.' }, diff --git a/main/routes/auth.ts b/main/routes/auth.ts index 5ffba1131..1d7e05606 100644 --- a/main/routes/auth.ts +++ b/main/routes/auth.ts @@ -20,6 +20,8 @@ import { incrementFailedLogin, resetSuccessfulLogin, FAILED_LOGIN_LOCKOUT_MINUTES, + normalizeIpForRateLimit, + withPinVerificationLock, validatePassword, revokeToken, isTokenRevoked, @@ -355,7 +357,7 @@ router.get('/pin-users', authRateLimit({ max: 600 }), (_req: Request, res: Respo router.post( '/pin-login', - loginAuthRateLimit(), + authRateLimit({ max: 600, skipSuccessfulRequests: true }), asyncHandler(async (req: Request, res: Response) => { try { const ip = req.ip || req.socket.remoteAddress || 'unknown'; @@ -363,99 +365,140 @@ router.post( const userId = typeof user_id === 'string' ? user_id.trim() : ''; const rawPin = typeof pin === 'string' || typeof pin === 'number' ? String(pin) : ''; - if (!userId || !rawPin) { - return res.status(400).json({ error: 'user_id and pin are required' }); - } - const db = getDatabase(); - const rateLimitKey = `${ip}:pin:${userId}`; - const rateLimit = checkLoginRateLimit(rateLimitKey); - if (!rateLimit.allowed) { - recordAuthAudit(db, { - userId, - eventType: 'pin_login', - outcome: 'rate_limited', - ipAddress: ip, - details: { wait_minutes: rateLimit.waitMinutes }, - }); - return res.status(429).json({ - error: `Too many failed attempts. Try again in ${rateLimit.waitMinutes} minutes.`, - }); - } - - if (!/^\d{4,6}$/.test(rawPin)) { - const attemptsRemaining = incrementFailedLogin(rateLimitKey); + if (!userId || !rawPin) { recordAuthAudit(db, { - userId, + userId: userId || null, eventType: 'pin_login', outcome: 'failure', ipAddress: ip, details: { - reason: 'invalid_pin_format', - attempts_remaining: attemptsRemaining, + reason: + !userId && !rawPin + ? 'missing_credentials' + : !userId + ? 'missing_user_id' + : 'missing_pin', }, }); - return res.status(401).json({ - error: 'Invalid credentials', - attempts_remaining: attemptsRemaining, - lockout_minutes: attemptsRemaining === 0 ? FAILED_LOGIN_LOCKOUT_MINUTES : undefined, - }); + return res.status(400).json({ error: 'user_id and pin are required' }); } - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as - | { - id: string; - name: string; - email: string; - role: string; - is_active: number; - pin_hash: string | null; - category_ids: string | null; + const canonicalIp = normalizeIpForRateLimit(ip); + const rateLimitKey = `${canonicalIp}:pin:${userId}`; + type VerifiedUser = { + id: string; + name: string; + email: string; + role: string; + is_active: number; + pin_hash: string | null; + category_ids: string | null; + }; + type PinLoginOutcome = + | { status: 429; body: Record } + | { status: 401; body: Record } + | { status: 200; user: VerifiedUser }; + + const verificationResult = await withPinVerificationLock( + rateLimitKey, + async () => { + const rateLimit = checkLoginRateLimit(rateLimitKey); + if (!rateLimit.allowed) { + recordAuthAudit(db, { + userId, + eventType: 'pin_login', + outcome: 'rate_limited', + ipAddress: ip, + details: { wait_minutes: rateLimit.waitMinutes }, + }); + return { + status: 429, + body: { + error: `Too many failed attempts. Try again in ${rateLimit.waitMinutes} minutes.`, + }, + }; } - | undefined; - let pinMatches = false; - if (user && user.is_active === 1 && user.pin_hash) { - try { - pinMatches = await verifyPinAsync(user.pin_hash, rawPin); - } catch { - pinMatches = false; - } - } - if (!user || user.is_active !== 1 || !user.pin_hash || !pinMatches) { - const attemptsRemaining = incrementFailedLogin(rateLimitKey); - recordAuthAudit(db, { - userId, - eventType: 'pin_login', - outcome: 'failure', - ipAddress: ip, - details: { - reason: !user - ? 'user_not_found' - : user.is_active !== 1 - ? 'user_inactive' - : !user.pin_hash - ? 'pin_not_set' - : 'invalid_pin', - attempts_remaining: attemptsRemaining, - }, - }); - return res.status(401).json({ - error: 'Invalid credentials', - attempts_remaining: attemptsRemaining, - lockout_minutes: attemptsRemaining === 0 ? FAILED_LOGIN_LOCKOUT_MINUTES : undefined, - }); - } + if (!/^\d{4,6}$/.test(rawPin)) { + const attemptsRemaining = incrementFailedLogin(rateLimitKey); + recordAuthAudit(db, { + userId, + eventType: 'pin_login', + outcome: 'failure', + ipAddress: ip, + details: { + reason: 'invalid_pin_format', + attempts_remaining: attemptsRemaining, + }, + }); + return { + status: 401, + body: { + error: 'Invalid credentials', + attempts_remaining: attemptsRemaining, + lockout_minutes: attemptsRemaining === 0 ? FAILED_LOGIN_LOCKOUT_MINUTES : undefined, + }, + }; + } - resetSuccessfulLogin(rateLimitKey); - recordAuthAudit(db, { - userId: user.id, - eventType: 'pin_login', - outcome: 'success', - ipAddress: ip, - details: { role: user.role }, - }); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as + | VerifiedUser + | undefined; + let pinMatches = false; + if (user && user.is_active === 1 && user.pin_hash) { + try { + pinMatches = await verifyPinAsync(user.pin_hash, rawPin); + } catch { + pinMatches = false; + } + } + + if (!user || user.is_active !== 1 || !user.pin_hash || !pinMatches) { + const attemptsRemaining = incrementFailedLogin(rateLimitKey); + recordAuthAudit(db, { + userId, + eventType: 'pin_login', + outcome: 'failure', + ipAddress: ip, + details: { + reason: !user + ? 'user_not_found' + : user.is_active !== 1 + ? 'user_inactive' + : !user.pin_hash + ? 'pin_not_set' + : 'invalid_pin', + attempts_remaining: attemptsRemaining, + }, + }); + return { + status: 401, + body: { + error: 'Invalid credentials', + attempts_remaining: attemptsRemaining, + lockout_minutes: attemptsRemaining === 0 ? FAILED_LOGIN_LOCKOUT_MINUTES : undefined, + }, + }; + } + + resetSuccessfulLogin(rateLimitKey); + recordAuthAudit(db, { + userId: user.id, + eventType: 'pin_login', + outcome: 'success', + ipAddress: ip, + details: { role: user.role }, + }); + return { status: 200, user }; + }, + ); + + if (verificationResult.status !== 200) { + return res.status(verificationResult.status).json(verificationResult.body); + } + const { user } = verificationResult; const remember = !!rememberMe; const token = jwt.sign( { userId: user.id, email: user.email, role: user.role, remember, jti: randomUUID() }, diff --git a/main/routes/bills.ts b/main/routes/bills.ts index 689ae3805..92c06a5a5 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -5284,37 +5284,6 @@ router.patch( preVerifiedChargePinHash = verifiedResult.user.pin_hash ?? undefined; } - const existingBreakdown = bill.charges_breakdown ?? order.charges_breakdown; - const toggleIds = chargeIdsAfterToggle({ - existingBreakdown, - definitions, - toggle: { - chargeId, - waived: waivedRequested as boolean | undefined, - applied: appliedRequested as boolean | undefined, - }, - }); - const resolved = resolveOrderCharges({ - definitions, - orderType: order.type, - subtotal: order.subtotal || 0, - discountAmount: order.discount_amount || 0, - currency: getTenantCurrency(), - existingBreakdown, - waivedIds: toggleIds.waivedIds, - optedInIds: toggleIds.optedInIds, - }); - const nextCharges = resolved.charges; - const decimals = getCurrencyFractionDigits(getTenantCurrency()); - const rawColumns = toStandardChargeColumns(nextCharges, decimals); - const serviceCharge = resolved.ownsServiceChargeColumn - ? rawColumns.service_charge - : Number(order.service_charge || 0); - const packagingCharge = resolved.ownsPackagingChargeColumn - ? rawColumns.packaging_charge - : Number(order.packaging_charge || 0); - const chargesJson = serializeAppliedCharges(nextCharges); - const updated = withTxn(() => { if (approvedByUserId && preVerifiedChargePinHash) { const approver = db @@ -5334,11 +5303,96 @@ router.patch( throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); } } - const totals = calculateOrderTotals(db, order.id); - const customer = order.customer_id + + const currentBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) as + | ChargeBillRow + | undefined; + if (!currentBill) { + throw Object.assign(new Error('Bill not found'), { + statusCode: 404, + diagnosticCode: 'bill_not_found', + }); + } + if (currentBill.split_group_id) { + throw Object.assign(new Error('Charges on a split check cannot be changed'), { + statusCode: 409, + diagnosticCode: 'split_check', + }); + } + if (currentBill.payment_status === 'paid' || Number(currentBill.paid_amount || 0) > 0) { + throw Object.assign(new Error('Charges cannot be changed on a paid bill'), { + statusCode: 409, + diagnosticCode: 'bill_paid', + }); + } + if ( + currentBill.payment_status === 'refunded' || + currentBill.payment_status === 'partially_refunded' + ) { + throw Object.assign(new Error('Charges cannot be changed on a refunded bill'), { + statusCode: 409, + diagnosticCode: 'bill_refunded', + }); + } + + const currentOrder = db + .prepare('SELECT * FROM orders WHERE id = ?') + .get(currentBill.order_id) as ChargeOrderRow | undefined; + if (!currentOrder) { + throw Object.assign(new Error('Order not found'), { + statusCode: 404, + diagnosticCode: 'order_not_found', + }); + } + if (currentOrder.status === 'cancelled') { + throw Object.assign(new Error('Charges cannot be changed on a cancelled order'), { + statusCode: 409, + diagnosticCode: 'order_cancelled', + }); + } + if (!definition.order_types.includes(currentOrder.type)) { + throw Object.assign(new Error('Charge does not apply to this order type'), { + statusCode: 409, + diagnosticCode: 'order_type_unsupported', + }); + } + + const totals = calculateOrderTotals(db, currentOrder.id); + const existingBreakdown = currentBill.charges_breakdown ?? currentOrder.charges_breakdown; + const toggleIds = chargeIdsAfterToggle({ + existingBreakdown, + definitions, + toggle: { + chargeId, + waived: waivedRequested as boolean | undefined, + applied: appliedRequested as boolean | undefined, + }, + }); + const resolved = resolveOrderCharges({ + definitions, + orderType: currentOrder.type, + subtotal: totals.subtotal, + discountAmount: currentOrder.discount_amount || 0, + currency: getTenantCurrency(), + existingBreakdown, + waivedIds: toggleIds.waivedIds, + optedInIds: toggleIds.optedInIds, + }); + const nextCharges = resolved.charges; + const decimals = getCurrencyFractionDigits(getTenantCurrency()); + const rawColumns = toStandardChargeColumns(nextCharges, decimals); + const serviceCharge = resolved.ownsServiceChargeColumn + ? rawColumns.service_charge + : Number(currentOrder.service_charge || 0); + const packagingCharge = resolved.ownsPackagingChargeColumn + ? rawColumns.packaging_charge + : Number(currentOrder.packaging_charge || 0); + const chargesJson = serializeAppliedCharges(nextCharges); + + const customer = currentOrder.customer_id ? (db .prepare('SELECT * FROM customers WHERE id = ?') - .get(order.customer_id) as Customer | null) + .get(currentOrder.customer_id) as Customer | null) : null; const { taxRollup, total } = recomputeOrderTotals({ tenantInfo: { @@ -5349,13 +5403,13 @@ router.patch( taxes_enabled: getSettingValue('taxes_enabled') === 'true', }, chargeContext: { - ...order, + ...currentOrder, service_charge: serviceCharge, packaging_charge: packagingCharge, }, customer, totals, - discountAmount: order.discount_amount || 0, + discountAmount: currentOrder.discount_amount || 0, taxScaling: 'when-discounted', appliedCharges: nextCharges, }); @@ -5370,17 +5424,17 @@ router.patch( taxRollup.taxAmount, JSON.stringify(taxRollup.breakdowns), taxRollup.snapshotJson, - order.discount_amount || 0, + currentOrder.discount_amount || 0, serviceCharge, packagingCharge, chargesJson, total, now(), - order.id, + currentOrder.id, ); recordOrderAudit(db, { - orderId: String(order.id), + orderId: String(currentOrder.id), actorUserId: String((req as Request & { user?: { userId?: string } }).user?.userId || ''), action: 'order_charge_updated', details: { @@ -5393,14 +5447,14 @@ router.patch( syncUnpaidBillsForOrder( db, - order.id, + currentOrder.id, { subtotal: totals.subtotal, taxAmount: taxRollup.taxAmount, taxBreakdown: JSON.stringify(taxRollup.breakdowns), taxSnapshot: taxRollup.snapshotJson, - discountAmount: order.discount_amount || 0, - deliveryCharge: order.delivery_charge || 0, + discountAmount: currentOrder.discount_amount || 0, + deliveryCharge: currentOrder.delivery_charge || 0, packagingCharge, serviceCharge, chargesBreakdown: chargesJson, @@ -5410,7 +5464,7 @@ router.patch( ); const updatedBill = parseRowJson( - db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id), + db.prepare('SELECT * FROM bills WHERE id = ?').get(currentBill.id), ); transactionBodyCompleted = true; return updatedBill; @@ -5422,15 +5476,21 @@ router.patch( res.json({ bill: updated }); } catch (error) { const statusCode = (error as { statusCode?: number }).statusCode || 500; + const diagnosticCode = (error as { diagnosticCode?: string }).diagnosticCode; + if (statusCode === 403 && !diagnosticCode) { + return res.status(403).json({ error: (error as Error).message }); + } console.error('[API] Bill charge update failed:', error); const outcome = operationCommitted ? 'applied' : transactionBodyCompleted ? 'unknown' : 'not_applied'; - const capturePoint = operationCommitted - ? 'fc.backend.bill.charge.completion' - : 'fc.backend.bill.charge.persistence'; + const capturePoint = diagnosticCode + ? 'fc.backend.bill.charge.validation' + : operationCommitted + ? 'fc.backend.bill.charge.completion' + : 'fc.backend.bill.charge.persistence'; res .status(statusCode) .json( @@ -5438,7 +5498,7 @@ router.patch( req, capturePoint, statusCode >= 500 ? 'Internal server error' : (error as Error).message, - undefined, + diagnosticCode, outcome, ), ); diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 703ef1d17..3a38b314d 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -1747,24 +1747,39 @@ router.get( }, ); -router.get('/auto_lock_minutes', settingsReadRateLimit, (_req: Request, res: Response) => { - try { - const setting = getDatabase() - .prepare('SELECT * FROM settings WHERE key = ?') - .get('auto_lock_minutes'); - if (setting) return res.json({ setting }); - return res.json({ - setting: { - key: 'auto_lock_minutes', - value: OPTIONAL_SETTING_DEFAULTS.auto_lock_minutes, - updated_at: null, - }, - }); - } catch (error) { - console.error('[API] Internal error:', error); - return res.status(500).json({ error: 'Internal server error' }); - } -}); +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', + ), + (_req: Request, res: Response) => { + try { + const setting = getDatabase() + .prepare('SELECT * FROM settings WHERE key = ?') + .get('auto_lock_minutes'); + if (setting) return res.json({ setting }); + return res.json({ + setting: { + key: 'auto_lock_minutes', + value: OPTIONAL_SETTING_DEFAULTS.auto_lock_minutes, + updated_at: null, + }, + }); + } catch (error) { + console.error('[API] Internal error:', error); + return res.status(500).json({ error: 'Internal server error' }); + } + }, +); router.get('/offline-mode', requirePermission('settings.view'), (_req: Request, res: Response) => { res.json({ offline_mode: isOfflineMode() }); diff --git a/tests/pin-quick-switch.test.ts b/tests/pin-quick-switch.test.ts index 0a986aa61..1dfea8f4d 100644 --- a/tests/pin-quick-switch.test.ts +++ b/tests/pin-quick-switch.test.ts @@ -6,8 +6,6 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -process.env.FLO_AUTH_RATE_LIMIT_MAX = '100'; - const Module = require('module'); const frontendRequire = Module.createRequire(path.join(process.cwd(), 'frontend/package.json')); const originalLoad = Module._load; @@ -15,7 +13,7 @@ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-pin-quick-switch-')); let mockApiHandler: { get?: (url: string) => Promise; - post?: (url: string, body?: any) => Promise; + post?: (url: string, body?: any, config?: any) => Promise; } = {}; const mockPosSettingsStore = { @@ -59,7 +57,7 @@ Module._load = function (reqName: string, parent: unknown, isMain: boolean) { if (reqName === '@/lib/api') { return { get: (...args: [string]) => mockApiHandler.get?.(...args), - post: (...args: [string, any]) => mockApiHandler.post?.(...args), + post: (...args: [string, any, any]) => mockApiHandler.post?.(...args), }; } if (reqName === '@/store/pos-settings') { @@ -255,6 +253,37 @@ async function run() { .send({ user_id: 'server-no-pin', pin: '2468' }); assertEqualOrThrow(noPinLogin.status, 401, 'User without PIN is rejected on pin-login'); + // Missing user_id or pin returns 400 and records failure in auth_audit_log + const missingUserRes = await request(app).post('/api/auth/pin-login').send({ pin: '2468' }); + assertEqualOrThrow(missingUserRes.status, 400, 'Missing user_id returns 400'); + const missingPinRes = await request(app).post('/api/auth/pin-login').send({ user_id: 'owner-1' }); + assertEqualOrThrow(missingPinRes.status, 400, 'Missing pin returns 400'); + const missingAuditRows = db + .prepare( + "SELECT user_id, outcome, details_json FROM auth_audit_log WHERE event_type = 'pin_login' AND outcome = 'failure' ORDER BY id ASC", + ) + .all() as Array<{ user_id: string | null; outcome: string; details_json: string | null }>; + assertOrThrow( + missingAuditRows.some( + (r) => r.user_id === null && String(r.details_json).includes('missing_user_id'), + ), + 'Missing user_id is recorded in auth_audit_log', + ); + assertOrThrow( + missingAuditRows.some( + (r) => r.user_id === 'owner-1' && String(r.details_json).includes('missing_pin'), + ), + 'Missing pin is recorded in auth_audit_log', + ); + + // Repeated successful quick switches from one IP (>10 times) succeed without 429 + for (let i = 0; i < 12; i++) { + const switchRes = await request(app) + .post('/api/auth/pin-login') + .send({ user_id: i % 2 === 0 ? 'owner-1' : cashierId, pin: '2468' }); + assertEqualOrThrow(switchRes.status, 200, `Quick switch #${i + 1} from shared IP returns 200`); + } + // ── 4. Per-user rate limiting and audit logging on POST /api/auth/pin-login ── console.log('\n4. Wrong PIN rate limiting per user and auth_audit_log rows'); resetSuccessfulLogin(`127.0.0.1:pin:${cashierId}`); @@ -313,6 +342,24 @@ async function run() { resetSuccessfulLogin(`127.0.0.1:pin:${cashierId}`); + // Concurrent wrong-PIN burst is serialized per user and cannot bypass the 5-attempt lockout + const concurrentWrongResponses = await Promise.all( + Array.from({ length: 8 }, () => + request(app).post('/api/auth/pin-login').send({ user_id: cashierId, pin: '0000' }), + ), + ); + assertEqualOrThrow( + concurrentWrongResponses.filter((r) => r.status === 401).length, + 5, + 'Concurrent wrong-PIN burst allows exactly 5 attempts (401) before lockout', + ); + assertEqualOrThrow( + concurrentWrongResponses.filter((r) => r.status === 429).length, + 3, + 'Remaining 3 concurrent wrong-PIN requests receive 429 rate-limited', + ); + resetSuccessfulLogin(`127.0.0.1:pin:${cashierId}`); + // ── 5. Cashier PIN rejected on EVERY approval endpoint ── console.log('\n5. Cashier PIN rejected on every approval endpoint'); const cashierAuth = makeToken(cashierId, 'cashier', 'cashier@test.local'); @@ -659,6 +706,13 @@ async function run() { // ── 6. Auto-lock setting validation ── console.log('\n6. Idle auto-lock store setting (off by default; 1, 5, 15, or 30 minutes)'); + const unauthAutoLock = await request(app).get('/api/settings/auto_lock_minutes'); + assertEqualOrThrow( + unauthAutoLock.status, + 401, + 'Unauthenticated GET /api/settings/auto_lock_minutes returns 401', + ); + const defaultAutoLock = await request(app) .get('/api/settings/auto_lock_minutes') .set(cashierAuth); @@ -723,10 +777,14 @@ async function run() { const staleMePromise = new Promise((res) => { staleMeResolver = res; }); + const capturedLogoutHeaders: Array = []; mockApiHandler = { - post: async (url: string, body?: any) => { - if (url === '/auth/logout') return { data: { message: 'Logged out' } }; + post: async (url: string, body?: any, config?: any) => { + if (url === '/auth/logout') { + capturedLogoutHeaders.push(config?.headers?.Authorization); + return { data: { message: 'Logged out' } }; + } if (url === '/auth/pin-login') { return { data: { @@ -794,12 +852,23 @@ async function run() { // Start an in-flight refreshAuthContext() for owner-1 before locking/switching const inFlightRefresh = useAuthStore.getState().refreshAuthContext(); + mockPosSettingsStore.state.setAutoLockMinutes(15); // Lock terminal await useAuthStore.getState().lock(); assertEqualOrThrow(useAuthStore.getState().user, null, 'lock() clears active user'); assertEqualOrThrow(useAuthStore.getState().token, null, 'lock() clears active token'); assertEqualOrThrow(useAuthStore.getState().isLocked, true, 'lock() sets isLocked = true'); + assertEqualOrThrow( + capturedLogoutHeaders[capturedLogoutHeaders.length - 1], + 'Bearer token-for-owner-1', + 'lock() passes Authorization Bearer header to POST /auth/logout before clearing storage', + ); + assertEqualOrThrow( + mockPosSettingsStore.state.autoLockMinutes, + 0, + 'lock() resets autoLockMinutes to 0', + ); assertEqualOrThrow( useCartStore.getState().items.length, 1, From f1469aa140b63d6c7d968f3f9c1c9a29219d5cc1 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 01:49:03 -0400 Subject: [PATCH 05/22] no-mistakes(document): Update PIN login and auto-lock documentation contracts --- docs/architecture/authentication-and-authorization.md | 9 +++++---- docs/architecture/frontend.md | 4 ++-- docs/reference/api.md | 8 ++++---- frontend/e2e/pin-quick-switch.spec.ts | 7 ++++++- frontend/src/store/auth.ts | 4 ++-- tests/pin-quick-switch.test.ts | 7 +++++++ 6 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index 13ce8b020..3c7f92d60 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -202,10 +202,11 @@ terminals, while approval authority stays restricted to active `owner` and `mana picker and sign in with email and password. 2. **Selected-user PIN sign-in (`POST /api/auth/pin-login`):** Accepts `{ user_id, pin, rememberMe }` and verifies the PIN strictly against the selected `user_id` via `verifyPinAsync` without - scanning other users, so two staff members may share the same PIN. Failed attempts are tracked by - the install-wide login rate limiter per user (`${normalizedIp}:pin:${userId}`), and every attempt - is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in `'success'`, - `'failure'`, or `'rate_limited'`). + scanning other users, so two staff members may share the same PIN. Verification and rate-limit + checks are serialized per user key (`${normalizedIp}:pin:${userId}`) with + `withPinVerificationLock`, failed attempts are tracked by the install-wide login rate limiter per + user, and every attempt is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in + `'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) diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 06b56978c..b5b6c6e4f 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -436,10 +436,10 @@ hook because it carries the transport: | Store | Holds | Persistence | | --- | --- | --- | -| [`auth.ts`](../../frontend/src/store/auth.ts) | `user`, `token`, `tenants`, `currentTenant`, `loading`, and the locales that failed to warm at bootstrap | token and tenant in `localStorage`; throws `StorageUnavailableError` when storage is unavailable | +| [`auth.ts`](../../frontend/src/store/auth.ts) | `user`, `token`, `tenants`, `currentTenant`, `loading`, `isLocked`, and the locales that failed to warm at bootstrap | token and tenant in `localStorage`; throws `StorageUnavailableError` when storage is unavailable | | [`cart.ts`](../../frontend/src/store/cart.ts) | cart items with their addons, instructions and selected product variant, order type, table, customer, guest count, delivery details, and the subtotal and item count selectors | none | | [`held-orders.ts`](../../frontend/src/store/held-orders.ts) | suspended orders keyed by table, and the fetch, hold, restore, remove, and lookup actions over `/held-orders` | none | -| [`pos-settings.ts`](../../frontend/src/store/pos-settings.ts) | the tenant's POS configuration, printer defaults, bill template and provenance, receipt and kitchen-ticket language policy | `persist` under the `pos-settings` key, version 5; `partialize` excludes `whatsappEnabled`, `billLanguagePolicy`, and `kotLanguagePolicy` | +| [`pos-settings.ts`](../../frontend/src/store/pos-settings.ts) | the tenant's POS configuration, printer defaults, bill template and provenance, receipt and kitchen-ticket language policy, and idle `autoLockMinutes` | `persist` under the `pos-settings` key, version 5; `partialize` excludes `whatsappEnabled`, `billLanguagePolicy`, `kotLanguagePolicy`, and `autoLockMinutes` | | [`theme.ts`](../../frontend/src/store/theme.ts) | `mode` and whether this session made an explicit choice | none; `useThemeModeToggle` writes through the API and rolls the store back when the write fails | | [`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts) | connection status, the hardware and WebUSB printer records, the print method, the last emitted bytes, and the print actions: `printBill`, `printTaxBill`, `printKot`, `printDeliverySlip` | `persist` | diff --git a/docs/reference/api.md b/docs/reference/api.md index 7404f4d1a..86f80b699 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -105,8 +105,8 @@ same. `loginAuthRateLimit` and `authRateLimit` deliberately do **not** exempt private addresses, so LAN clients are counted against authentication rate limits. -`loginAuthRateLimit` is the shared volume limiter across the login endpoints -(`POST /api/auth/login` on `3001`, `3002`, and `3003`, and `POST /api/auth/pin-login` on `3001`). It reuses a single shared budget +`loginAuthRateLimit` is the shared volume limiter across all three default login endpoints +(`POST /api/auth/login` on `3001`, `3002`, and `3003`). It reuses a single shared budget (default 10 requests per 15 minutes, configurable via `FLO_AUTH_RATE_LIMIT_MAX`). In addition, the three password login endpoints share an install-wide failed-login attempt lockout keyed to canonical client IP (`normalizeIpForRateLimit`), and `POST /api/auth/pin-login` keys its failed-login lockout per user (`${normalizedIp}:pin:${user_id}`): 5 failed credential attempts trigger a 15-minute lockout (`429`). Successful login @@ -158,7 +158,7 @@ Router: `main/routes/auth.ts`. Full path: `/api/auth`. | ------ | -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST` | `/login` | none, `loginAuthRateLimit` + shared failed-login limiter | body: `email`, `password`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenants: [ ... ] }`. `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts across local login listeners. | | `GET` | `/pin-users` | none, auth limiter (600 per window) | none | `{ users: [ { id, name, role } ] }` for active users with a PIN configured. | -| `POST` | `/pin-login` | none, `loginAuthRateLimit` + per-user failed-login limiter | body: `user_id`, `pin`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenant, tenants: [ ... ] }`. `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts for that IP and `user_id`. | +| `POST` | `/pin-login` | none, auth limiter (600 failed per window) + per-user failed-login limiter | body: `user_id`, `pin`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenant, tenants: [ ... ] }`. `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts for that IP and `user_id`. | | `POST` | `/tenants/select` | any authenticated role | none | `{ access_token, token_type: "bearer", tenant }`. | | `POST` | `/logout` | any authenticated role | none | `{ message }`. Revokes the presented token. | | `POST` | `/refresh` | any authenticated role | none | `{ access_token, token_type: "bearer", expires_in }`. | @@ -647,7 +647,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` | any authenticated role + settings read limiter | none | `{ setting: { key: "auto_lock_minutes", value, updated_at } }` (defaults to `"0"` when unset). | +| `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` | `/` | `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. | diff --git a/frontend/e2e/pin-quick-switch.spec.ts b/frontend/e2e/pin-quick-switch.spec.ts index 9454af903..254edd469 100644 --- a/frontend/e2e/pin-quick-switch.spec.ts +++ b/frontend/e2e/pin-quick-switch.spec.ts @@ -111,8 +111,13 @@ test.describe('PIN Quick User Switch and Lock Flow (#1338)', () => { await page.getByTestId('pos-product-card').first().click(); await expect(page.getByTestId('cart-item')).toHaveCount(1); - // Click Lock in the POS topbar + // Click Lock in the POS topbar and verify POST /api/auth/logout succeeds with 200 + const logoutResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + return response.request().method() === 'POST' && url.pathname === '/api/auth/logout'; + }); await page.getByTestId('pos-lock-button').click(); + expect((await logoutResponse).status()).toBe(200); await page.waitForURL('**/auth/login**', { timeout: 15000 }); // Verify user tile picker is visible and shows our PIN-enabled cashier diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 5e4ed7891..5fef79966 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -150,7 +150,7 @@ export const useAuthStore = create((set, get) => ({ const logoutPromise = api .post( '/auth/logout', - null, + undefined, tokenToRevoke ? { headers: { Authorization: `Bearer ${tokenToRevoke}` } } : undefined, ) .catch(() => {}); @@ -199,7 +199,7 @@ export const useAuthStore = create((set, get) => ({ api .post( '/auth/logout', - null, + undefined, tokenToRevoke ? { headers: { Authorization: `Bearer ${tokenToRevoke}` } } : undefined, ) .catch(() => {}); diff --git a/tests/pin-quick-switch.test.ts b/tests/pin-quick-switch.test.ts index 1dfea8f4d..f35f7c805 100644 --- a/tests/pin-quick-switch.test.ts +++ b/tests/pin-quick-switch.test.ts @@ -778,10 +778,12 @@ async function run() { staleMeResolver = res; }); const capturedLogoutHeaders: Array = []; + const capturedLogoutBodies: Array = []; mockApiHandler = { post: async (url: string, body?: any, config?: any) => { if (url === '/auth/logout') { + capturedLogoutBodies.push(body); capturedLogoutHeaders.push(config?.headers?.Authorization); return { data: { message: 'Logged out' } }; } @@ -864,6 +866,11 @@ async function run() { 'Bearer token-for-owner-1', 'lock() passes Authorization Bearer header to POST /auth/logout before clearing storage', ); + assertEqualOrThrow( + capturedLogoutBodies[capturedLogoutBodies.length - 1], + undefined, + 'lock() passes undefined body to POST /auth/logout so express.json() accepts the empty body', + ); assertEqualOrThrow( mockPosSettingsStore.state.autoLockMinutes, 0, From 91478eb7548b5b4a6affefbbc8334dcf0b070e9b Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 02:03:06 -0400 Subject: [PATCH 06/22] no-mistakes(ci): Updated `frontend/e2e/approval-manager-selection.spec.ts` line 270 to assert `page.getByText('Invalid Staff Approval PIN')` instead of `'Invalid manager PIN'`, matching the updated `pos.kitchenDeliveryOverrideInvalid` translation (`"Invalid Staff Approval PIN. Please try again."`) rendered by `PaymentModal.tsx` when `POST /api/bills/:id/payments` returns 403 `Invalid manager PIN`. The downstream `End-to-End Playwright Tests` and `Required Checks Gate` CI failures were caused by this Shard 1/4 E2E failure. Verification run and passed: - `npm run lint` - `npm run build && npm run build:frontend` - `npm run i18n:check` - `cd frontend && npx playwright test --config=playwright.config.ts --project=chromium e2e/approval-manager-selection.spec.ts e2e/pin-quick-switch.spec.ts` (4 passed) - `cd frontend && npx playwright test --config=playwright.config.ts --project=chromium e2e/kitchen-delivery-payment-retry.spec.ts e2e/kitchen/kitchen-delivery-billing-gate.spec.ts` (5 passed) - `node scripts/ci/check-changed-format.cjs --base 1278f914a7cc05ab07c3edf49d6c8e67e25a525d` - `git diff --check` --- frontend/e2e/approval-manager-selection.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/e2e/approval-manager-selection.spec.ts b/frontend/e2e/approval-manager-selection.spec.ts index 7cdcf4ff5..8cee961d1 100644 --- a/frontend/e2e/approval-manager-selection.spec.ts +++ b/frontend/e2e/approval-manager-selection.spec.ts @@ -267,7 +267,7 @@ test.describe('Explicit Manager Selection for Approvals and Kitchen Override', ( expect(rejectedBody.error).toBe('Invalid manager PIN'); // Error alert should be visible in the modal - await expect(page.getByText('Invalid manager PIN')).toBeVisible(); + await expect(page.getByText('Invalid Staff Approval PIN')).toBeVisible(); await page.screenshot({ path: path.join(EVIDENCE_DIR, 'scenario-2-mismatched-manager-pin-rejected.png'), }); From 05da388de1b0f90c97dc016f7b549d3c85a8d2cd Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 02:53:53 -0400 Subject: [PATCH 07/22] feat(billing): correct paid bill payment method --- docs/reference/api.md | 1 + docs/reference/roles-and-permissions.md | 9 + frontend/src/app/(dashboard)/orders/page.tsx | 23 + .../orders/CorrectPaymentMethodModal.tsx | 361 ++++++++ .../components/orders/OrderDetailPanel.tsx | 29 + frontend/src/lib/i18n/messages/ar.json | 7 +- frontend/src/lib/i18n/messages/bn.json | 7 +- frontend/src/lib/i18n/messages/de.json | 7 +- frontend/src/lib/i18n/messages/en.json | 7 +- frontend/src/lib/i18n/messages/es.json | 7 +- frontend/src/lib/i18n/messages/fa.json | 7 +- frontend/src/lib/i18n/messages/fil.json | 7 +- frontend/src/lib/i18n/messages/fr.json | 7 +- frontend/src/lib/i18n/messages/hi.json | 7 +- frontend/src/lib/i18n/messages/id.json | 7 +- frontend/src/lib/i18n/messages/it.json | 7 +- frontend/src/lib/i18n/messages/ja.json | 7 +- frontend/src/lib/i18n/messages/ko.json | 7 +- frontend/src/lib/i18n/messages/ne.json | 7 +- frontend/src/lib/i18n/messages/nl.json | 7 +- frontend/src/lib/i18n/messages/pt.json | 7 +- frontend/src/lib/i18n/messages/ru.json | 7 +- frontend/src/lib/i18n/messages/sq.json | 7 +- frontend/src/lib/i18n/messages/th.json | 7 +- frontend/src/lib/i18n/messages/tr.json | 7 +- frontend/src/lib/i18n/messages/ur.json | 7 +- frontend/src/lib/i18n/messages/vi.json | 7 +- frontend/src/lib/i18n/messages/zh-tw.json | 7 +- frontend/src/lib/i18n/messages/zh.json | 7 +- main/routes/bills.ts | 863 ++++++++++++++++++ package.json | 3 +- tests/payment-method-correction.test.ts | 835 +++++++++++++++++ 32 files changed, 2267 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/orders/CorrectPaymentMethodModal.tsx create mode 100644 tests/payment-method-correction.test.ts diff --git a/docs/reference/api.md b/docs/reference/api.md index 86f80b699..4ad546425 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -405,6 +405,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` | `payments.take` + active `owner` or `manager` Staff Approval PIN | path: `id`, `index`; body: `method`, optional `payment_method_id`, `transaction_id`, `reason`, `override_pin`, `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. Requires an active `owner` or `manager` Staff Approval PIN (`403` when missing, invalid, or belonging to a cashier/waiter; `429` when rate-limited). Returns `409` when the payment's cash shift is closed (`shift_closed`), the store business day is closed (`day_closed`), the bill or a split sibling has been refunded (`bill_refunded`, `split_sibling_refunded`) or settled in a closed shift (`split_sibling_shift_closed`), the target custom payment method is inactive (`payment_method_inactive`), or `transaction_id` is used on another bill. 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` 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` when the order is cancelled. | | `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. | diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index c9fd3264f..3e5bd00eb 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -171,6 +171,15 @@ manager PIN (with `payments.take` when overriding unsettled payment), even when is already an owner or manager. Full safeguard, restock, and audit rules are in [the Orders API reference](api.md#orders). +**Paid bill payment method correction.** Staff holding `payments.take` can correct the tender method +on a recorded payment line of a `paid` bill (`PATCH /api/bills/:id/payments/:index/method`) only +while the payment's cash shift (`cash_sessions`) and the store business day remain open, and only +with an active owner or manager Staff Approval PIN (`override_pin`, `approver_id`). A cashier's own +PIN never authorizes this action (`403`). Once the shift or business day is closed, or if the bill +or a split sibling has been refunded or settled in a closed shift, the backend refuses the +correction with `409`. The payment amount is never modified, and every correction is recorded in +`order_audit_log` under `bill.payment_method.corrected`. + **Owner visibility.** The permission editor itself is rendered only for an authenticated user holding `authorization.manage` — an active owner, always. The API enforces this independently; hiding a control in the interface is not a security boundary. diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index bc66873aa..9b28563de 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -59,6 +59,7 @@ import { classifyOrderRequestFailure } from '@/lib/order-attempt'; import { OrderCard } from '@/components/orders/OrderCard'; import { OrdersMasterList } from '@/components/orders/OrdersMasterList'; import { OrderDetailPanel } from '@/components/orders/OrderDetailPanel'; +import CorrectPaymentMethodModal from '@/components/orders/CorrectPaymentMethodModal'; import EditOrderItemModal from '@/components/orders/EditOrderItemModal'; import { PriceOverrideModal } from '@/components/orders/PriceOverrideModal'; import { useOrdersLayoutPreference } from '@/hooks/useOrdersLayout'; @@ -189,6 +190,10 @@ export default function OrdersPage() { const [tabFilter, setTabFilter] = useState('active'); const [paymentBill, setPaymentBill] = useState(null); const [refundModal, setRefundModal] = useState<{ order: Order; bills: Bill[] } | null>(null); + const [correctPaymentModal, setCorrectPaymentModal] = useState<{ + order: Order; + bills: Bill[]; + } | null>(null); const [tables, setTables] = useState([]); // null until /settings/kds_enabled resolves; the WebSocket stays closed // until the feature is confirmed on, because a disabled KDS refuses the @@ -1876,6 +1881,11 @@ export default function OrdersPage() { onPayBill={handlePayBill} onAddItems={openAddItemsModal} onRefund={(ord, bills) => setRefundModal({ order: ord, bills })} + onCorrectPaymentMethod={ + tenantCan(currentTenant, 'payments.take') + ? (ord, bills) => setCorrectPaymentModal({ order: ord, bills }) + : undefined + } onConvertToTakeaway={handleConvertToTakeaway} onCancelOrder={(ord) => setCancelModal({ @@ -2048,6 +2058,19 @@ export default function OrdersPage() { /> )} + {/* Correct Payment Method Modal */} + {correctPaymentModal && ( + setCorrectPaymentModal(null)} + onCorrected={() => { + setCorrectPaymentModal(null); + fetchOrders(undefined, { refreshLoadedPages: true }); + }} + /> + )} + {/* Print Confirmation Modal */} {confirmPrintBillId !== null && (
diff --git a/frontend/src/components/orders/CorrectPaymentMethodModal.tsx b/frontend/src/components/orders/CorrectPaymentMethodModal.tsx new file mode 100644 index 000000000..513e20987 --- /dev/null +++ b/frontend/src/components/orders/CorrectPaymentMethodModal.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ApproverSelect } from '@/components/ApproverSelect'; +import { Ltr } from '@/components/layout/Ltr'; +import { useManagerApprovers } from '@/hooks/useManagerApprovers'; +import { useFormatCurrency } from '@/hooks/useFormatCurrency'; +import api from '@/lib/api'; +import { createPaymentIdempotencyKey } from '@/lib/payment-idempotency'; +import { PAYMENT_METHODS, type CustomPaymentMethod } from '@/lib/payment-methods'; +import { normalizeBillPaymentDetails } from './OrderDetailPanel'; +import type { Bill, Order } from '@/lib/types'; +import toast from 'react-hot-toast'; +import { useTranslations } from 'use-intl'; + +const BUILT_IN_PAYMENT_KEYS = { + cash: 'methodCash', + card: 'methodCard', +} as const; + +function extractErrorMessage(error: unknown): string | null { + const data = (error as { response?: { data?: { error?: unknown } } } | undefined)?.response?.data; + return typeof data?.error === 'string' && data.error.trim() ? data.error.trim() : null; +} + +interface Props { + order: Order; + bills: Bill[]; + onClose: () => void; + onCorrected: (updatedBill?: Bill) => void; +} + +export default function CorrectPaymentMethodModal({ order, bills, onClose, onCorrected }: Props) { + const tOrders = useTranslations('orders'); + const tPos = useTranslations('pos'); + const tCommon = useTranslations('common'); + const fmt = useFormatCurrency(); + + const [selectedBillId, setSelectedBillId] = useState(() => bills[0]?.id ?? 0); + const effectiveBill = bills.find((b) => b.id === selectedBillId) ?? bills[0] ?? null; + + const paymentLines = normalizeBillPaymentDetails(effectiveBill?.payment_details); + const [selectedIndex, setSelectedIndex] = useState(0); + + const [customMethods, setCustomMethods] = useState([]); + const [methodSelection, setMethodSelection] = useState(() => { + const firstMethod = String(paymentLines[0]?.method || '').toLowerCase(); + return firstMethod === 'cash' ? 'card' : 'cash'; + }); + const [transactionId, setTransactionId] = useState(() => + paymentLines[0]?.transaction_id ? String(paymentLines[0].transaction_id) : '', + ); + const [reason, setReason] = useState(''); + const [overridePin, setOverridePin] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [idempotencyKey, setIdempotencyKey] = useState(() => createPaymentIdempotencyKey()); + + const managerApprovers = useManagerApprovers(); + + const currentLine = paymentLines[selectedIndex] ?? paymentLines[0] ?? null; + + const syncLineDefaults = (line: (typeof paymentLines)[number] | null | undefined) => { + const currentMethod = String(line?.method || '').toLowerCase(); + setMethodSelection(currentMethod === 'cash' ? 'card' : 'cash'); + setTransactionId(line?.transaction_id ? String(line.transaction_id) : ''); + setIdempotencyKey(createPaymentIdempotencyKey()); + }; + + useEffect(() => { + let cancelled = false; + api + .get('/payment-methods') + .then((res) => { + if (!cancelled) { + setCustomMethods( + (res.data?.payment_methods || []).filter((m: CustomPaymentMethod) => + Boolean(m.is_active), + ), + ); + } + }) + .catch(() => { + if (!cancelled) setCustomMethods([]); + }); + return () => { + cancelled = true; + }; + }, []); + + const formatLineMethod = (method: string) => { + const lower = String(method || '').toLowerCase(); + if (lower === 'cash') return tPos('methodCash'); + if (lower === 'card') return tPos('methodCard'); + if (lower === 'wallet') return tPos('methodWallet'); + return method; + }; + + const selectedCustomMethod = methodSelection.startsWith('custom:') + ? (customMethods.find((m) => `custom:${m.id}` === methodSelection) ?? null) + : null; + const resolvedMethodName = selectedCustomMethod ? selectedCustomMethod.name : methodSelection; + const isCashSelected = resolvedMethodName.toLowerCase() === 'cash' && !selectedCustomMethod; + + const canSubmit = + Boolean(effectiveBill) && + Boolean(currentLine) && + !submitting && + !managerApprovers.loading && + !managerApprovers.loadFailed && + Boolean(managerApprovers.selectedApproverId) && + /^\d{4,6}$/.test(overridePin); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!effectiveBill || !currentLine || !canSubmit) return; + + setSubmitting(true); + try { + const payload: Record = { + method: selectedCustomMethod ? 'custom' : methodSelection, + ...(selectedCustomMethod ? { payment_method_id: selectedCustomMethod.id } : {}), + ...(!isCashSelected && transactionId.trim() + ? { transaction_id: transactionId.trim() } + : {}), + ...(reason.trim() ? { reason: reason.trim() } : {}), + approver_id: managerApprovers.selectedApproverId, + override_pin: overridePin, + }; + const { data } = await api.patch( + `/bills/${effectiveBill.id}/payments/${selectedIndex}/method`, + payload, + { + headers: { + 'Idempotency-Key': idempotencyKey, + }, + }, + ); + toast.success(tOrders('paymentMethodCorrected')); + onCorrected(data?.bill); + } catch (error) { + const msg = extractErrorMessage(error) || tOrders('correctPaymentMethodFailed'); + toast.error(msg); + setIdempotencyKey(createPaymentIdempotencyKey()); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ {tOrders('correctPaymentMethod')} #{order.order_number} +

+

{tOrders('correctPaymentMethodHint')}

+ + + {bills.length > 1 && ( +
+ + +
+ )} + +
+ + {paymentLines.length > 1 ? ( + + ) : currentLine ? ( +
+ + {formatLineMethod(currentLine.method)} + + + {fmt(Number(currentLine.amount))} + +
+ ) : null} +
+ +
+ + +
+ + {!isCashSelected && ( +
+ + setTransactionId(e.target.value)} + placeholder={tOrders('transactionNumber')} + className="w-full px-3 py-2 border border-border bg-card rounded-lg text-sm text-foreground" + /> +
+ )} + +
+ + setReason(e.target.value)} + className="w-full px-3 py-2 border border-border bg-card rounded-lg text-sm text-foreground" + /> +
+ +
+ { + managerApprovers.setSelectedApproverId(id); + setOverridePin(''); + }} + loading={managerApprovers.loading} + loadFailed={managerApprovers.loadFailed} + disabled={submitting} + /> +
+ + setOverridePin(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder={tOrders('managerPin')} + className="w-full px-3 py-2 border border-border bg-card rounded-lg text-sm text-foreground" + /> +

+ {tOrders('refundApprovalPinHint')} +

+
+
+ +
+ + +
+ +
+
+ ); +} diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index 252fa4789..eb795dcca 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -92,6 +92,7 @@ export interface OrderDetailPanelProps { onPayBill?: (bill: Bill) => void; onAddItems: (order: Order) => void; onRefund: (order: Order, bills: Bill[]) => void; + onCorrectPaymentMethod?: (order: Order, bills: Bill[]) => void; onConvertToTakeaway: (order: Order) => void; onCancelOrder: (order: Order) => void; onPrint: (billId: number) => void; @@ -151,6 +152,7 @@ function OrderDetailContent({ onPayBill, onAddItems, onRefund, + onCorrectPaymentMethod, onConvertToTakeaway, onCancelOrder, onPrint, @@ -211,6 +213,13 @@ function OrderDetailContent({ const paidBills = orderBills.filter( (b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded', ); + const correctablePaidBills = orderBills.filter( + (b) => + b.payment_status === 'paid' && + normalizeBillPaymentDetails(b.payment_details).some( + (line) => String(line.method || '').toLowerCase() !== 'wallet', + ), + ); const hasEligibleRefund = paidBills.length > 0; const hasCollectedFunds = orderBills.some((candidate) => { const status = String(candidate.payment_status || ''); @@ -372,6 +381,15 @@ function OrderDetailContent({ {tOrders('downloadPrintPreview')} )} + {onCorrectPaymentMethod && correctablePaidBills.length > 0 && ( + onCorrectPaymentMethod(order, correctablePaidBills)} + > + + {tOrders('correctPaymentMethod')} + + )} {!['completed', 'cancelled'].includes(order.status) && ( <> @@ -931,6 +949,17 @@ function OrderDetailContent({ {tOrders('sendReceipt')} )} + {onCorrectPaymentMethod && correctablePaidBills.length > 0 && ( + + )} {canRefund && hasEligibleRefund && (
{xReport && ( <> + {(xReport.openShifts?.length ?? 0) > 0 && ( +
+ + +
+ )}
{exportMenu('x')}
{(xReport.alreadyClosed || alreadyClosedOverride) && (
@@ -265,6 +294,15 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) { name: blockingOpenShift.opened_by_name, })}

+ {(xReport.openShifts?.length ?? 0) > 1 && ( +
    + {xReport.openShifts?.map((s) => ( +
  • + #{s.id} - {s.opened_by_name} +
  • + ))} +
+ )}
)} diff --git a/frontend/src/components/dashboard/ShiftCloseModal.tsx b/frontend/src/components/dashboard/ShiftCloseModal.tsx index d3b8f8fd0..4a22db223 100644 --- a/frontend/src/components/dashboard/ShiftCloseModal.tsx +++ b/frontend/src/components/dashboard/ShiftCloseModal.tsx @@ -22,6 +22,10 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { closeModalOpen, setCloseModalOpen, session, + activeSession, + openSessions = [], + selectedSessionId, + setSelectedSessionId, countedInput, setCountedInput, submitting, @@ -39,6 +43,7 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { setSubmitError, closePreview, } = model; + const targetSession = activeSession ?? session; const tPos = useTranslations('pos'); const unpaidInShiftCount = closePreview?.unpaid_in_shift.count ?? closePreview?.unpaid_in_shift.bill_count ?? 0; @@ -78,8 +83,35 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { {submitError}
)} - {!closedResult && session && ( + {!closedResult && targetSession && ( <> + {openSessions.length > 1 && ( +
+ + +
+ )} {unpaidInShiftCount > 0 && (
{t('expectedCash')} - {fmt(session.expected_cash_cents / minorFactor)} + {fmt(targetSession.expected_cash_cents / minorFactor)}
@@ -201,7 +233,7 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { - diff --git a/frontend/src/components/layout/StatusBar.tsx b/frontend/src/components/layout/StatusBar.tsx index 1c7645a61..7114b3374 100644 --- a/frontend/src/components/layout/StatusBar.tsx +++ b/frontend/src/components/layout/StatusBar.tsx @@ -123,7 +123,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge? clearInterval(interval); window.removeEventListener('flo-shift-changed', handleShiftChange); }; - }, []); + }, [user?.id]); useEffect(() => { let cancelled = false; diff --git a/frontend/src/components/pos/PosTopbar.tsx b/frontend/src/components/pos/PosTopbar.tsx index 6a9214cf0..bd9a46ab1 100644 --- a/frontend/src/components/pos/PosTopbar.tsx +++ b/frontend/src/components/pos/PosTopbar.tsx @@ -32,6 +32,7 @@ interface Props { onActionsMenuOpenChange: (open: boolean) => void; onShowCashMovement: () => void; onShowShift: () => void; + onShowCloseStaffShift?: () => void; shiftHasOpenSession: boolean; shiftLoading: boolean; shiftError: string | null; @@ -51,6 +52,7 @@ export default function PosTopbar({ onActionsMenuOpenChange, onShowCashMovement, onShowShift, + onShowCloseStaffShift, shiftHasOpenSession, shiftLoading, shiftError, @@ -126,6 +128,16 @@ export default function PosTopbar({ {shiftHasOpenSession ? tDashboard('closeShiftSession') : tDashboard('openShift')} )} + {canUseShift && !shiftHasOpenSession && onShowCloseStaffShift && ( + + + {tDashboard('closeShiftSession')} + + )} {tProducts('printMenu')} diff --git a/frontend/src/components/settings/PaymentMethodsSettings.tsx b/frontend/src/components/settings/PaymentMethodsSettings.tsx index 75df6d5fc..bde925ed3 100644 --- a/frontend/src/components/settings/PaymentMethodsSettings.tsx +++ b/frontend/src/components/settings/PaymentMethodsSettings.tsx @@ -35,6 +35,8 @@ export function PaymentMethodsSettings({ isAdmin }: { isAdmin: boolean }) { const [mergeTargets, setMergeTargets] = useState>({}); const [merges, setMerges] = useState([]); const [splitChecksEnabled, setSplitChecksEnabled] = useState(false); + const [requireOpenShift, setRequireOpenShift] = useState(false); + const [oneShiftPerStaff, setOneShiftPerStaff] = useState(false); const [cardTransactionIdRequired, setCardTransactionIdRequired] = useState(false); const [cardTransactionSettingState, setCardTransactionSettingState] = useState< 'loading' | 'ready' | 'error' @@ -63,19 +65,29 @@ export function PaymentMethodsSettings({ isAdmin }: { isAdmin: boolean }) { }; const load = async () => { - const [methodsRes, historyRes, splitRes] = await Promise.all([ - api.get('/payment-methods?include_inactive=true'), - api.get('/payment-methods/merge-history'), - api - .get('/settings/split_checks_enabled') - .catch(() => ({ data: { setting: { value: 'false' } } })), - loadCardTransactionSetting(), - ]); + const [methodsRes, historyRes, splitRes, requireShiftRes, perStaffShiftRes] = await Promise.all( + [ + api.get('/payment-methods?include_inactive=true'), + api.get('/payment-methods/merge-history'), + api + .get('/settings/split_checks_enabled') + .catch(() => ({ data: { setting: { value: 'false' } } })), + api + .get('/settings/require_open_shift') + .catch(() => ({ data: { setting: { value: 'false' } } })), + api + .get('/settings/one_shift_per_staff') + .catch(() => ({ data: { setting: { value: 'false' } } })), + loadCardTransactionSetting(), + ], + ); const rows = methodsRes.data.payment_methods || []; setMethods(rows); setNames(Object.fromEntries(rows.map((row: CustomPaymentMethod) => [row.id, row.name]))); setMerges(historyRes.data.merges || []); setSplitChecksEnabled(splitRes.data?.setting?.value === 'true'); + setRequireOpenShift(requireShiftRes.data?.setting?.value === 'true'); + setOneShiftPerStaff(perStaffShiftRes.data?.setting?.value === 'true'); }; useEffect(() => { @@ -312,6 +324,74 @@ export function PaymentMethodsSettings({ isAdmin }: { isAdmin: boolean }) { )}
+
+
+

+ {t('requireOpenShift')} +

+

+ {t('requireOpenShiftHint')} +

+
+ { + const value = e.target.checked; + setRequireOpenShift(value); + try { + await api.put('/settings/require_open_shift', { value: String(value) }); + } catch { + setRequireOpenShift(!value); + toast.error(t('saveFailed')); + } + }} + /> +
+ +
+
+

+ {t('oneShiftPerStaff')} +

+

+ {t('oneShiftPerStaffHint')} +

+

+ {t('oneShiftPerStaffSharedDrawerWarning')} +

+
+ { + const value = e.target.checked; + setOneShiftPerStaff(value); + try { + await api.put('/settings/one_shift_per_staff', { value: String(value) }); + } catch (err: unknown) { + setOneShiftPerStaff(!value); + const status = (err as { response?: { status?: number } })?.response?.status; + if (status === 409) { + toast.error(t('oneShiftPerStaffDisableBlocked')); + } else { + toast.error(t('saveFailed')); + } + } + }} + /> +
+ {merges.length > 0 && (

{t('mergeHistory')}

diff --git a/frontend/src/hooks/useCashClose.ts b/frontend/src/hooks/useCashClose.ts index cc4011f99..2b05228cb 100644 --- a/frontend/src/hooks/useCashClose.ts +++ b/frontend/src/hooks/useCashClose.ts @@ -62,6 +62,22 @@ interface XReport { priorClosedCashCents: number | null; priorBusinessDate: string | null; alreadyClosed: boolean; + session?: { + id: number; + opened_by: string; + opened_by_name: string; + opened_at: string; + closed_at: string | null; + status: string; + opening_float_cents: number; + expected_cash_cents: number; + } | null; + openShifts?: { + id: number; + opened_by: string; + opened_by_name: string; + opened_at: string; + }[]; blockingOpenShift?: { id: number; opened_by_name: string; @@ -158,6 +174,7 @@ export function useCashClose() { // already-closed day) the effect below jumps straight to 3 once the // x-report says `alreadyClosed: true`. const [closeStep, setCloseStep] = useState<1 | 2 | 3>(1); + const [selectedSessionId, setSelectedSessionId] = useState(null); // F3: derive a token from `openToken + businessDate`. The render-time // `if (closeOpen && xToken !== refXToken)` block wipes stale X-report // state when the token changes (modal open OR operator picks a different @@ -165,7 +182,7 @@ export function useCashClose() { // counter so the wipe is automatic — same pattern as the `syncKey` // block above (recommended by React for "adjusting state when a prop // changes" and avoids the cascading-renders lint rule). - const xToken = `${openToken}:${businessDate}`; + const xToken = `${openToken}:${businessDate}:${selectedSessionId ?? 'all'}`; const [refXToken, setRefXToken] = useState(xToken); const [openingFloatInput, setOpeningFloatInput] = useState(''); const [countedInput, setCountedInput] = useState(''); @@ -218,7 +235,13 @@ export function useCashClose() { const isActive = () => !controller.signal.aborted; // Use the operator-selected date so past-day closes work. api - .get('/reports/x-report', { params: { date: businessDate }, signal: controller.signal }) + .get('/reports/x-report', { + params: { + date: businessDate, + ...(selectedSessionId ? { session_id: selectedSessionId } : {}), + }, + signal: controller.signal, + }) .then((res) => { if (!isActive()) return; setXReport(res.data.xReport); @@ -311,7 +334,7 @@ export function useCashClose() { }); return () => controller.abort(); // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [closeOpen, openToken, businessDate]); + }, [closeOpen, openToken, businessDate, selectedSessionId]); // Reset modal state at the start of each new open (React-recommended // pattern for "adjusting state when a prop changes" — the equivalent of @@ -328,6 +351,7 @@ export function useCashClose() { setAlreadyClosedOverride(false); setBusinessDate(dateOverride ?? todayLocal); setDateOverride(null); + setSelectedSessionId(null); setOpeningFloatInput(''); setCountedInput(''); setSubmitError(null); @@ -631,6 +655,8 @@ export function useCashClose() { closeAnotherDay, businessDate, setBusinessDate, + selectedSessionId, + setSelectedSessionId, xReport, xLoading, xError, diff --git a/frontend/src/hooks/useCashSession.ts b/frontend/src/hooks/useCashSession.ts index 24eb2eace..936b7dee8 100644 --- a/frontend/src/hooks/useCashSession.ts +++ b/frontend/src/hooks/useCashSession.ts @@ -60,15 +60,20 @@ export interface CashSessionClosePreview { /** Result of a shift refresh. Superseded calls carry no session and must not * trigger routing because a newer refresh owns the current UI state. */ export type CashSessionRefreshResult = - | { status: 'ok'; session: CashSession | null; error: string | null } + | { + status: 'ok'; + session: CashSession | null; + openSessions: CashSession[]; + error: string | null; + } | { status: 'superseded' }; /** Shift (cash session) controller for the POS terminal: owns the * open/close modal state, current-session fetch, submit, and print. * Mirrors useCashClose but without the date picker, prior-close prefill, - * or already-closed hydration — sessions have exactly one open row. */ + * or already-closed hydration. */ export function useCashSession() { - const { currentTenant } = useAuthStore(); + const { currentTenant, user } = useAuthStore(); const t = useTranslations('dashboard'); const tCommon = useTranslations('common'); const fmt = useFormatCurrency(); @@ -76,6 +81,8 @@ export function useCashSession() { const minorFactor = getCurrencyMinorUnitFactor(currentTenant?.currency || ''); const [session, setSession] = useState(null); + const [openSessions, setOpenSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [openModalOpen, setOpenModalOpen] = useState(false); @@ -94,19 +101,33 @@ export function useCashSession() { // cascading-renders lint rule forbids effects from calling // setter-containing functions, even behind awaits.) /** Fetches the current shift without mutating hook state. */ - const fetchState = async (): Promise<{ data: CashSession | null; error: string | null }> => { + const fetchState = useCallback(async (): Promise<{ + data: CashSession | null; + allOpen: CashSession[]; + error: string | null; + }> => { try { - const res = await api.get('/cash-sessions/current'); - return { data: (res.data ?? null) as CashSession | null, error: null }; + const [res, openRes] = await Promise.all([ + api.get('/cash-sessions/current'), + api.get('/cash-sessions/open').catch(() => null), + ]); + const current = (res.data ?? null) as CashSession | null; + const allOpen: CashSession[] = Array.isArray(openRes?.data?.sessions) + ? (openRes.data.sessions as CashSession[]) + : current + ? [current] + : []; + return { data: current, allOpen, error: null }; } catch (err: unknown) { return { data: null, + allOpen: [], error: axios.isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load shift', }; } - }; + }, []); // Sequence guard and explicit superseded result: a stale response must not // write state or route shift entry when a newer request owns the UI. @@ -116,36 +137,49 @@ export function useCashSession() { const seq = ++refreshSeq.current; setLoading(true); setError(null); - const { data, error: loadError } = await fetchState(); + const { data, allOpen, error: loadError } = await fetchState(); if (seq !== refreshSeq.current) return { status: 'superseded' }; setSession(data); + setOpenSessions(allOpen); setError(loadError); setLoading(false); - return { status: 'ok', session: data, error: loadError }; - }, []); + return { status: 'ok', session: data, openSessions: allOpen, error: loadError }; + }, [fetchState]); - // Mount fetch: loading starts true, so state settles only in async - // continuations (lint-clean by construction). It participates in the same - // generation sequence as refresh so late mount data cannot win. + // Mount and active-user change fetch: re-runs when the active user changes + // (e.g. after PIN quick-switch on a shared terminal). useEffect(() => { let cancelled = false; const seq = ++refreshSeq.current; - fetchState().then(({ data, error: loadError }) => { + fetchState().then(({ data, allOpen, error: loadError }) => { if (cancelled || seq !== refreshSeq.current) return; setSession(data); + setOpenSessions(allOpen); setError(loadError); setLoading(false); }); return () => { cancelled = true; }; - }, []); + }, [fetchState, user?.id]); + + const activeSession = + (selectedSessionId !== null + ? openSessions.find((s) => s.id === selectedSessionId) + : undefined) ?? + session ?? + openSessions[0] ?? + null; useEffect(() => { - if (!closeModalOpen || !session) return; + if (!closeModalOpen || !activeSession) return; let cancelled = false; + const previewConfig = + session && activeSession.id === session.id + ? undefined + : { params: { session_id: activeSession.id } }; api - .get('/cash-sessions/current/close-preview') + .get('/cash-sessions/current/close-preview', previewConfig) .then((res) => { if (!cancelled) setClosePreview((res.data ?? null) as CashSessionClosePreview | null); }) @@ -155,7 +189,7 @@ export function useCashSession() { return () => { cancelled = true; }; - }, [closeModalOpen, session]); + }, [closeModalOpen, activeSession, session]); // Shared display→cents semantics with day close (lib/money.ts). const displayToCents = (raw: string): number | null => @@ -189,7 +223,7 @@ export function useCashSession() { /** Closes the open shift with the entered count, refreshes state, and * retains the closure result for Z printing. */ const closeShift = async () => { - if (!session) return; + if (!activeSession) return; const countedCents = displayToCents(countedInput); if (countedCents === null) { setSubmitError(t('movementAmountRequired')); @@ -198,7 +232,7 @@ export function useCashSession() { setSubmitting(true); setSubmitError(null); try { - const res = await api.post(`/cash-sessions/${session.id}/close`, { + const res = await api.post(`/cash-sessions/${activeSession.id}/close`, { counted_cash_cents: countedCents, }); setClosedResult(res.data); @@ -249,12 +283,16 @@ export function useCashSession() { const countedCentsOrNull = displayToCents(countedInput); const shiftLoadFailedMessage = t('shiftLoadFailed'); const variancePreviewCents = - session && countedCentsOrNull !== null - ? countedCentsOrNull - session.expected_cash_cents + activeSession && countedCentsOrNull !== null + ? countedCentsOrNull - activeSession.expected_cash_cents : null; return { session, + activeSession, + openSessions, + selectedSessionId, + setSelectedSessionId, loading, error, refresh, diff --git a/frontend/src/lib/i18n/messages/ar.json b/frontend/src/lib/i18n/messages/ar.json index 21845b6e7..06c2381a2 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {سيتم ترحيل طلب مفتوح واحد (#)} other {سيتم ترحيل # طلبات مفتوحة}}", "shiftCloseUnpaidWarningHint": "تظل هذه الطلبات مفتوحة للوردية التالية. إغلاق هذه الوردية يطابق فقط المدفوعات النقدية التي تم تحصيلها بالفعل خلال هذه الوردية.", "shiftCloseUnpaidMoreCount": "+{count} طلبات مفتوحة أخرى", - "lockTerminal": "قفل نقطة البيع" + "lockTerminal": "قفل نقطة البيع", + "selectOpenShift": "الوردية المراد إغلاقها", + "xReportShiftFilterLabel": "نطاق الوردية", + "xReportAllShifts": "جميع الورديات (اليوم بالكامل)" }, "printTest": { "amt": "المبلغ", @@ -2569,7 +2572,13 @@ "autoLockLabel": "القفل التلقائي عند الخمول", "autoLockHint": "قفل شاشة نقطة البيع تلقائياً بعد فترة من عدم النشاط", "autoLockOff": "إيقاف", - "autoLockMinutesOption": "{count} دقائق" + "autoLockMinutesOption": "{count} دقائق", + "requireOpenShift": "اشترط فتح وردية للمدفوعات النقدية", + "requireOpenShiftHint": "حظر المدفوعات النقدية والاسترداد النقدي وحركات الدرج ما لم تكن هناك وردية نشطة مفتوحة.", + "oneShiftPerStaff": "وردية واحدة لكل موظف", + "oneShiftPerStaffHint": "السماح لكل موظف بفتح وإغلاق وردية الصندوق الخاصة به بشكل مستقل.", + "oneShiftPerStaffSharedDrawerWarning": "ملاحظة: إذا اشتركت ورديتان متزامنتان في درج نقدي فعلي واحد، فلن تكون عمليات جرد النقد الفردية معزولة فعلياً.", + "oneShiftPerStaffDisableBlocked": "لا يمكن تعطيل خيار وردية واحدة لكل موظف بينما توجد عدة ورديات مفتوحة. أغلق الورديات الإضافية أولاً." }, "setup": { "alreadyCompleted": "تم الانتهاء من عملية الإعداد في هذا التثبيت. يتم إعادة التوجيه إلى صفحة تسجيل الدخول…", diff --git a/frontend/src/lib/i18n/messages/bn.json b/frontend/src/lib/i18n/messages/bn.json index e6f27092a..df4347855 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {#টি খোলা অর্ডার পরবর্তী শিফটে যাবে} other {#টি খোলা অর্ডার পরবর্তী শিফটে যাবে}}", "shiftCloseUnpaidWarningHint": "এই অর্ডারগুলো পরবর্তী শিফটের জন্য খোলা থাকবে। এই শিফট বন্ধ করলে শুধুমাত্র এই শিফটে ইতিমধ্যে সংগৃহীত নগদ পেমেন্টের হিসাব মেলানো হয়।", "shiftCloseUnpaidMoreCount": "+আরও {count}টি খোলা অর্ডার", - "lockTerminal": "টার্মিনাল লক করুন" + "lockTerminal": "টার্মিনাল লক করুন", + "selectOpenShift": "বন্ধ করার শিফট", + "xReportShiftFilterLabel": "শিফটের পরিধি", + "xReportAllShifts": "সব শিফট (সারাদিন)" }, "printTest": { "amt": "এএমটি", @@ -2569,7 +2572,13 @@ "autoLockLabel": "নিষ্ক্রিয় অটো-লক", "autoLockHint": "নিষ্ক্রিয়তার পর স্বয়ংক্রিয়ভাবে পিওএস টার্মিনাল লক করুন", "autoLockOff": "বন্ধ", - "autoLockMinutesOption": "{count} মিনিট" + "autoLockMinutesOption": "{count} মিনিট", + "requireOpenShift": "নগদের জন্য খোলা শিফট আবশ্যক করুন", + "requireOpenShiftHint": "সক্রিয় শিফট খোলা না থাকলে নগদ পেমেন্ট, নগদ রিফান্ড এবং ড্রয়ার লেনদেন ব্লক করুন।", + "oneShiftPerStaff": "প্রতি কর্মী একটি শিফট", + "oneShiftPerStaffHint": "প্রতিটি কর্মীকে স্বাধীনভাবে তাদের নিজস্ব ক্যাশ শিফট খোলা ও বন্ধ করার অনুমতি দিন।", + "oneShiftPerStaffSharedDrawerWarning": "দ্রষ্টব্য: যদি দুটি সমসাময়িক শিফট একটি একক ফিজিক্যাল ক্যাশ ড্রয়ার ভাগ করে, তবে পৃথক ড্রয়ারের নগদ গণনা ভৌতভাবে আলাদা থাকবে না।", + "oneShiftPerStaffDisableBlocked": "একাধিক শিফট খোলা থাকা অবস্থায় প্রতি কর্মী একটি শিফট নিষ্ক্রিয় করা যাবে না। প্রথমে অতিরিক্ত শিফটগুলো বন্ধ করুন।" }, "setup": { "alreadyCompleted": "এই ইনস্টলে সেটআপ ইতিমধ্যেই সম্পন্ন হয়েছে। লগইন করার জন্য পুনঃনির্দেশ করা হচ্ছে...", diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 856229aa7..4e9f96dd9 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# offene Bestellung wird übertragen} other {# offene Bestellungen werden übertragen}}", "shiftCloseUnpaidWarningHint": "Diese Bestellungen bleiben für die nächste Schicht geöffnet. Beim Schließen dieser Schicht werden nur die in dieser Schicht bereits kassierten Barzahlungen abgestimmt.", "shiftCloseUnpaidMoreCount": "+{count} weitere offene Bestellungen", - "lockTerminal": "Kasse sperren" + "lockTerminal": "Kasse sperren", + "selectOpenShift": "Zu schließende Schicht", + "xReportShiftFilterLabel": "Schichtbereich", + "xReportAllShifts": "Alle Schichten (ganzer Tag)" }, "printTest": { "amt": "Betrag", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Automatische Sperre bei Inaktivität", "autoLockHint": "Kassenterminal nach Inaktivität automatisch sperren", "autoLockOff": "Aus", - "autoLockMinutesOption": "{count} Min." + "autoLockMinutesOption": "{count} Min.", + "requireOpenShift": "Offene Schicht für Bargeld erforderlich", + "requireOpenShiftHint": "Barzahlungen, Barrückerstattungen und Kassenbewegungen ohne geöffnete Schicht blockieren.", + "oneShiftPerStaff": "Eine Schicht pro Mitarbeiter", + "oneShiftPerStaffHint": "Jedem Mitarbeiter erlauben, seine eigene Kassenschicht unabhängig zu öffnen und zu schließen.", + "oneShiftPerStaffSharedDrawerWarning": "Hinweis: Wenn zwei gleichzeitige Schichten dieselbe physische Kassenschublade teilen, sind die einzelnen Bargeldbestände physisch nicht getrennt.", + "oneShiftPerStaffDisableBlocked": "Eine Schicht pro Mitarbeiter kann nicht deaktiviert werden, solange mehrere Schichten geöffnet sind. Schließen Sie zuerst die zusätzlichen Schichten." }, "setup": { "alreadyCompleted": "Die Ersteinrichtung wurde bereits abgeschlossen. Weiterleitung zur Anmeldung…", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index f175311e0..bcab6da1e 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# open order will carry over} other {# open orders will carry over}}", "shiftCloseUnpaidWarningHint": "These orders stay open for the next shift. Closing this shift only reconciles cash payments already collected during this shift.", "shiftCloseUnpaidMoreCount": "+{count} more open orders", - "lockTerminal": "Lock Terminal" + "lockTerminal": "Lock Terminal", + "selectOpenShift": "Shift to close", + "xReportShiftFilterLabel": "Shift scope", + "xReportAllShifts": "All shifts (full day)" }, "printTest": { "amt": "Amt", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Idle Auto-Lock", "autoLockHint": "Automatically lock the POS terminal after inactivity", "autoLockOff": "Off", - "autoLockMinutesOption": "{count} min" + "autoLockMinutesOption": "{count} min", + "requireOpenShift": "Require Open Shift for Cash", + "requireOpenShiftHint": "Block cash payments, cash refunds, and drawer movements unless an active shift is open.", + "oneShiftPerStaff": "One shift per staff member", + "oneShiftPerStaffHint": "Allow each staff member to open and close their own cash shift independently.", + "oneShiftPerStaffSharedDrawerWarning": "Note: If two concurrent shifts share a single physical cash drawer, individual drawer cash counts will not be physically isolated.", + "oneShiftPerStaffDisableBlocked": "Cannot disable one shift per staff member while multiple shifts are open. Close extra shifts first." }, "setup": { "alreadyCompleted": "Setup has already been completed on this install. Redirecting to login…", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index dcdf4fc24..7016037eb 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# pedido abierto pasará al siguiente turno} other {# pedidos abiertos pasarán al siguiente turno}}", "shiftCloseUnpaidWarningHint": "Estos pedidos permanecen abiertos para el siguiente turno. Al cerrar este turno solo se concilian los pagos en efectivo ya cobrados durante este turno.", "shiftCloseUnpaidMoreCount": "+{count} pedidos abiertos más", - "lockTerminal": "Bloquear terminal" + "lockTerminal": "Bloquear terminal", + "selectOpenShift": "Turno a cerrar", + "xReportShiftFilterLabel": "Alcance del turno", + "xReportAllShifts": "Todos los turnos (día completo)" }, "printTest": { "amt": "Total", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Bloqueo automático por inactividad", "autoLockHint": "Bloquear automáticamente el terminal TPV tras inactividad", "autoLockOff": "Desactivado", - "autoLockMinutesOption": "{count} min." + "autoLockMinutesOption": "{count} min.", + "requireOpenShift": "Exigir turno abierto para efectivo", + "requireOpenShiftHint": "Bloquear cobros en efectivo, reembolsos en efectivo y movimientos de caja si no hay un turno abierto.", + "oneShiftPerStaff": "Un turno por empleado", + "oneShiftPerStaffHint": "Permitir que cada empleado abra y cierre su propio turno de caja de forma independiente.", + "oneShiftPerStaffSharedDrawerWarning": "Nota: Si dos turnos simultáneos comparten un único cajón físico, los conteos individuales de efectivo no estarán separados físicamente.", + "oneShiftPerStaffDisableBlocked": "No se puede desactivar un turno por empleado mientras haya varios turnos abiertos. Cierra primero los turnos adicionales." }, "setup": { "alreadyCompleted": "La configuración ya se completó en esta instalación. Redirigiendo al inicio de sesión…", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 748f16fcd..7d1dff6df 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# سفارش باز به شیفت بعد منتقل می‌شود} other {# سفارش باز به شیفت بعد منتقل می‌شوند}}", "shiftCloseUnpaidWarningHint": "این سفارش‌ها برای شیفت بعدی باز می‌مانند. بستن این شیفت فقط پرداخت‌های نقدی دریافت‌شده در همین شیفت را تسویه می‌کند.", "shiftCloseUnpaidMoreCount": "+{count} سفارش باز دیگر", - "lockTerminal": "قفل پایانه" + "lockTerminal": "قفل پایانه", + "selectOpenShift": "شیفت مورد نظر برای بستن", + "xReportShiftFilterLabel": "محدوده شیفت", + "xReportAllShifts": "همه شیفت‌ها (کل روز)" }, "printTest": { "amt": "مبلغ", @@ -2569,7 +2572,13 @@ "autoLockLabel": "قفل خودکار در زمان عدم فعالیت", "autoLockHint": "قفل خودکار پایانه فروش پس از مدتی عدم فعالیت", "autoLockOff": "خاموش", - "autoLockMinutesOption": "{count} دقیقه" + "autoLockMinutesOption": "{count} دقیقه", + "requireOpenShift": "الزام به باز بودن شیفت برای وجه نقد", + "requireOpenShiftHint": "مسدود کردن پرداخت‌های نقدی، استرداد نقدی و جابجایی‌های صندوق در صورت باز نبودن شیفت فعال.", + "oneShiftPerStaff": "یک شیفت برای هر کارمند", + "oneShiftPerStaffHint": "به هر یک از کارکنان اجازه دهید شیفت صندوق خود را به صورت مستقل باز و بسته کنند.", + "oneShiftPerStaffSharedDrawerWarning": "توجه: اگر دو شیفت هم‌زمان از یک کشوی پول فیزیکی مشترک استفاده کنند، شمارش نقدی هر شیفت به صورت فیزیکی تفکیک نخواهد شد.", + "oneShiftPerStaffDisableBlocked": "تا زمانی که چند شیفت باز است نمی‌توان گزینه یک شیفت برای هر کارمند را غیرفعال کرد. ابتدا شیفت‌های اضافی را ببندید." }, "setup": { "alreadyCompleted": "راه‌اندازی در این نصب پیش‌تر انجام شده است. در حال بازگشت به ورود…", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index e74a7dc47..529585c0b 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# bukas na order ang ililipat sa susunod na shift} other {# bukas na mga order ang ililipat sa susunod na shift}}", "shiftCloseUnpaidWarningHint": "Mananatiling bukas ang mga order na ito para sa susunod na shift. Ang pagsasara ng shift na ito ay nagtutugma lamang sa mga bayad na cash na nakolekta na sa shift na ito.", "shiftCloseUnpaidMoreCount": "+{count} pang bukas na order", - "lockTerminal": "I-lock ang Terminal" + "lockTerminal": "I-lock ang Terminal", + "selectOpenShift": "Shift na isasara", + "xReportShiftFilterLabel": "Saklaw ng shift", + "xReportAllShifts": "Lahat ng shift (buong araw)" }, "printTest": { "amt": "Halaga", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Auto-Lock kapag Idle", "autoLockHint": "Awtomatikong i-lock ang POS terminal kapag walang aktibidad", "autoLockOff": "Naka-off", - "autoLockMinutesOption": "{count} minuto" + "autoLockMinutesOption": "{count} minuto", + "requireOpenShift": "Kailangan ng Bukas na Shift para sa Cash", + "requireOpenShiftHint": "Harangan ang mga bayad na cash, refund na cash, at galaw sa kaha maliban kung may bukas na shift.", + "oneShiftPerStaff": "Isang shift bawat kawani", + "oneShiftPerStaffHint": "Payagan ang bawat kawani na magbukas at magsara ng sarili nilang cash shift nang hiwalay.", + "oneShiftPerStaffSharedDrawerWarning": "Paalala: Kung ang dalawang magkasabay na shift ay gumagamit ng iisang pisikal na kaha, hindi pisikal na magkahiwalay ang bilang ng pera bawat shift.", + "oneShiftPerStaffDisableBlocked": "Hindi maaaring i-off ang isang shift bawat kawani habang maraming shift ang nakabukas. Isara muna ang mga karagdagang shift." }, "setup": { "alreadyCompleted": "Nakumpleto na ang setup sa installation na ito. Nireredirect sa pag-login…", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index dae2b3da0..f16321a6b 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# commande ouverte sera reportée} other {# commandes ouvertes seront reportées}}", "shiftCloseUnpaidWarningHint": "Ces commandes restent ouvertes pour le service suivant. La clôture de ce service ne rapproche que les paiements en espèces déjà encaissés pendant ce service.", "shiftCloseUnpaidMoreCount": "+{count} autres commandes ouvertes", - "lockTerminal": "Verrouiller le terminal" + "lockTerminal": "Verrouiller le terminal", + "selectOpenShift": "Service à fermer", + "xReportShiftFilterLabel": "Portée du service", + "xReportAllShifts": "Tous les services (journée entière)" }, "printTest": { "amt": "Montant", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Verrouillage automatique", "autoLockHint": "Verrouiller automatiquement le terminal de caisse après inactivité", "autoLockOff": "Désactivé", - "autoLockMinutesOption": "{count} min." + "autoLockMinutesOption": "{count} min.", + "requireOpenShift": "Exiger un service ouvert pour les espèces", + "requireOpenShiftHint": "Bloquer les paiements en espèces, les remboursements en espèces et les mouvements de caisse sans service actif.", + "oneShiftPerStaff": "Un service par employé", + "oneShiftPerStaffHint": "Permettre à chaque employé d’ouvrir et de fermer son propre service de caisse de manière indépendante.", + "oneShiftPerStaffSharedDrawerWarning": "Remarque : Si deux services simultanés partagent un même tiroir-caisse physique, les comptages individuels ne seront pas physiquement isolés.", + "oneShiftPerStaffDisableBlocked": "Impossible de désactiver un service par employé lorsque plusieurs services sont ouverts. Fermez d’abord les services supplémentaires." }, "setup": { "alreadyCompleted": "La configuration a déjà été effectuée sur cette installation. Redirection vers la connexion…", diff --git a/frontend/src/lib/i18n/messages/hi.json b/frontend/src/lib/i18n/messages/hi.json index a95bd37ae..0b27d402e 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# खुला ऑर्डर अगली शिफ्ट में आगे जाएगा} other {# खुले ऑर्डर अगली शिफ्ट में आगे जाएंगे}}", "shiftCloseUnpaidWarningHint": "ये ऑर्डर अगली शिफ्ट के लिए खुले रहते हैं। इस शिफ्ट को बंद करने पर केवल इस शिफ्ट के दौरान पहले से प्राप्त नकद भुगतानों का मिलान होता है।", "shiftCloseUnpaidMoreCount": "+{count} और खुले ऑर्डर", - "lockTerminal": "टर्मिनल लॉक करें" + "lockTerminal": "टर्मिनल लॉक करें", + "selectOpenShift": "बंद करने के लिए शिफ्ट", + "xReportShiftFilterLabel": "शिफ्ट का दायरा", + "xReportAllShifts": "सभी शिफ्ट (पूरा दिन)" }, "printTest": { "amt": "राशि", @@ -2569,7 +2572,13 @@ "autoLockLabel": "निष्क्रिय ऑटो-लॉक", "autoLockHint": "निष्क्रिय रहने पर पीओएस टर्मिनल को स्वतः लॉक करें", "autoLockOff": "बंद", - "autoLockMinutesOption": "{count} मिनट" + "autoLockMinutesOption": "{count} मिनट", + "requireOpenShift": "नकद के लिए खुली शिफ्ट आवश्यक करें", + "requireOpenShiftHint": "जब तक कोई सक्रिय शिफ्ट खुली न हो, नकद भुगतान, नकद रिफंड और ड्रॉअर गतिविधियों को रोकें।", + "oneShiftPerStaff": "प्रति स्टाफ सदस्य एक शिफ्ट", + "oneShiftPerStaffHint": "प्रत्येक स्टाफ सदस्य को अपनी कैश शिफ्ट स्वतंत्र रूप से खोलने और बंद करने की अनुमति दें।", + "oneShiftPerStaffSharedDrawerWarning": "नोट: यदि दो समवर्ती शिफ्ट एक ही भौतिक कैश ड्रॉअर साझा करती हैं, तो व्यक्तिगत नकद गणना भौतिक रूप से अलग नहीं होगी।", + "oneShiftPerStaffDisableBlocked": "एक से अधिक शिफ्ट खुली होने पर प्रति स्टाफ सदस्य एक शिफ्ट को अक्षम नहीं किया जा सकता। पहले अतिरिक्त शिफ्ट बंद करें।" }, "setup": { "alreadyCompleted": "इस इंस्टाल पर सेटअप पहले ही पूरा हो चुका है. लॉगिन करने के लिए पुनः निर्देशित किया जा रहा है...", diff --git a/frontend/src/lib/i18n/messages/id.json b/frontend/src/lib/i18n/messages/id.json index e44f57363..3bba0fb5f 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# pesanan terbuka akan dilanjutkan ke shift berikutnya} other {# pesanan terbuka akan dilanjutkan ke shift berikutnya}}", "shiftCloseUnpaidWarningHint": "Pesanan ini tetap terbuka untuk shift berikutnya. Menutup shift ini hanya mencocokkan pembayaran tunai yang sudah diterima selama shift ini.", "shiftCloseUnpaidMoreCount": "+{count} pesanan terbuka lainnya", - "lockTerminal": "Kunci Terminal" + "lockTerminal": "Kunci Terminal", + "selectOpenShift": "Shift yang akan ditutup", + "xReportShiftFilterLabel": "Cakupan shift", + "xReportAllShifts": "Semua shift (sehari penuh)" }, "printTest": { "amt": "Jml", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Kunci Otomatis Saat Idle", "autoLockHint": "Kunci terminal POS secara otomatis setelah tidak ada aktivitas", "autoLockOff": "Mati", - "autoLockMinutesOption": "{count} menit" + "autoLockMinutesOption": "{count} menit", + "requireOpenShift": "Wajibkan Shift Terbuka untuk Tunai", + "requireOpenShiftHint": "Blokir pembayaran tunai, pengembalian dana tunai, dan mutasi laci kecuali ada shift aktif yang terbuka.", + "oneShiftPerStaff": "Satu shift per staf", + "oneShiftPerStaffHint": "Izinkan setiap anggota staf untuk membuka dan menutup shift kasir mereka sendiri secara mandiri.", + "oneShiftPerStaffSharedDrawerWarning": "Catatan: Jika dua shift bersamaan berbagi satu laci uang fisik yang sama, perhitungan uang tunai per shift tidak terpisah secara fisik.", + "oneShiftPerStaffDisableBlocked": "Tidak dapat menonaktifkan satu shift per staf saat beberapa shift masih terbuka. Tutup shift tambahan terlebih dahulu." }, "setup": { "alreadyCompleted": "Pengaturan awal sudah selesai dilakukan pada instalasi ini. Mengalihkan ke layar masuk…", diff --git a/frontend/src/lib/i18n/messages/it.json b/frontend/src/lib/i18n/messages/it.json index 30b732e4c..365b0d6a5 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# ordine aperto verrà trasferito} other {# ordini aperti verranno trasferiti}}", "shiftCloseUnpaidWarningHint": "Questi ordini restano aperti per il turno successivo. La chiusura di questo turno riconcilia solo i pagamenti in contanti già incassati durante questo turno.", "shiftCloseUnpaidMoreCount": "+{count} altri ordini aperti", - "lockTerminal": "Blocca terminale" + "lockTerminal": "Blocca terminale", + "selectOpenShift": "Turno da chiudere", + "xReportShiftFilterLabel": "Ambito del turno", + "xReportAllShifts": "Tutti i turni (intera giornata)" }, "printTest": { "amt": "Importo", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Blocco automatico per inattività", "autoLockHint": "Blocca automaticamente il terminale POS dopo inattività", "autoLockOff": "Disattivato", - "autoLockMinutesOption": "{count} min." + "autoLockMinutesOption": "{count} min.", + "requireOpenShift": "Richiedi turno aperto per i contanti", + "requireOpenShiftHint": "Blocca pagamenti in contanti, rimborsi in contanti e movimenti di cassa se non c’è un turno attivo.", + "oneShiftPerStaff": "Un turno per dipendente", + "oneShiftPerStaffHint": "Consenti a ciascun dipendente di aprire e chiudere il proprio turno di cassa in modo indipendente.", + "oneShiftPerStaffSharedDrawerWarning": "Nota: Se due turni simultanei condividono un unico cassetto contanti fisico, i conteggi individuali non saranno isolati fisicamente.", + "oneShiftPerStaffDisableBlocked": "Impossibile disattivare un turno per dipendente mentre più turni sono aperti. Chiudi prima gli altri turni." }, "setup": { "alreadyCompleted": "La configurazione è già stata completata su questa installazione. Reindirizzamento all'accesso…", diff --git a/frontend/src/lib/i18n/messages/ja.json b/frontend/src/lib/i18n/messages/ja.json index f72d6b216..86be5f719 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# 件の未会計注文が次のシフトに引き継がれます} other {# 件の未会計注文が次のシフトに引き継がれます}}", "shiftCloseUnpaidWarningHint": "これらの注文は次のシフトに引き継がれます。このシフトの精算では、このシフト中に受け取った現金支払いのみを集計します。", "shiftCloseUnpaidMoreCount": "他 +{count} 件の未会計注文", - "lockTerminal": "端末をロック" + "lockTerminal": "端末をロック", + "selectOpenShift": "閉じるシフト", + "xReportShiftFilterLabel": "シフト範囲", + "xReportAllShifts": "すべてのシフト(終日)" }, "printTest": { "amt": "金額", @@ -2569,7 +2572,13 @@ "autoLockLabel": "アイドル自動ロック", "autoLockHint": "一定時間操作がない場合にPOS端末を自動ロックします", "autoLockOff": "オフ", - "autoLockMinutesOption": "{count} 分" + "autoLockMinutesOption": "{count} 分", + "requireOpenShift": "現金取引にシフト開始を必須にする", + "requireOpenShiftHint": "有効なシフトが開いていない限り、現金支払い、現金返金、ドロワー入出金をブロックします。", + "oneShiftPerStaff": "スタッフごとに1シフト", + "oneShiftPerStaffHint": "各スタッフが自分のレジシフトを個別に開始・終了できるようにします。", + "oneShiftPerStaffSharedDrawerWarning": "注意:2つの同時シフトが1つの物理キャッシュドロワーを共有する場合、個別の現金カウントは物理的に分離されません。", + "oneShiftPerStaffDisableBlocked": "複数のシフトが開いている間は、スタッフごとに1シフトを無効にできません。先に他のシフトを閉じてください。" }, "setup": { "alreadyCompleted": "このインストールではセットアップが完了しています。ログインへ移動します…", diff --git a/frontend/src/lib/i18n/messages/ko.json b/frontend/src/lib/i18n/messages/ko.json index ee1b6a9d1..bca5347eb 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {미결제 주문 #건이 다음 교대근무로 이월됩니다} other {미결제 주문 #건이 다음 교대근무로 이월됩니다}}", "shiftCloseUnpaidWarningHint": "이 주문들은 다음 교대근무를 위해 열린 상태로 유지됩니다. 이번 교대근무 마감은 이번 교대근무 중에 이미 수납된 현금 결제만 정산합니다.", "shiftCloseUnpaidMoreCount": "+{count}건의 미결제 주문 더보기", - "lockTerminal": "단말기 잠금" + "lockTerminal": "단말기 잠금", + "selectOpenShift": "닫을 교대조", + "xReportShiftFilterLabel": "교대조 범위", + "xReportAllShifts": "모든 교대조 (종일)" }, "printTest": { "amt": "금액", @@ -2569,7 +2572,13 @@ "autoLockLabel": "자리 비움 자동 잠금", "autoLockHint": "일정 시간 사용하지 않으면 POS 단말기를 자동으로 잠급니다", "autoLockOff": "끔", - "autoLockMinutesOption": "{count}분" + "autoLockMinutesOption": "{count}분", + "requireOpenShift": "현금 거래 시 열린 교대조 필요", + "requireOpenShiftHint": "활성 교대조가 열려 있지 않으면 현금 결제, 현금 환불 및 금전함 입출금을 차단합니다.", + "oneShiftPerStaff": "직원당 하나의 교대조", + "oneShiftPerStaffHint": "각 직원이 자신의 현금 교대조를 독립적으로 열고 닫을 수 있도록 허용합니다.", + "oneShiftPerStaffSharedDrawerWarning": "참고: 두 개의 동시 교대조가 하나의 물리적 현금 서랍을 공유하는 경우 개별 현금 집계가 물리적으로 분리되지 않습니다.", + "oneShiftPerStaffDisableBlocked": "여러 교대조가 열려 있는 동안에는 직원당 하나의 교대조 설정을 끌 수 없습니다. 먼저 추가 교대조를 닫으세요." }, "setup": { "alreadyCompleted": "이 설치에서는 초기 설정이 이미 완료되었습니다. 로그인으로 이동합니다…", diff --git a/frontend/src/lib/i18n/messages/ne.json b/frontend/src/lib/i18n/messages/ne.json index 0f73a916b..f3e9ca054 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# खुला अर्डर अर्को सिफ्टमा सर्नेछ} other {# खुला अर्डरहरू अर्को सिफ्टमा सर्नेछन्}}", "shiftCloseUnpaidWarningHint": "यी अर्डरहरू अर्को सिफ्टका लागि खुला नै रहन्छन्। यो सिफ्ट बन्द गर्दा यस सिफ्टमा पहिले नै संकलन गरिएका नगद भुक्तानी मात्र मिलान गरिन्छ।", "shiftCloseUnpaidMoreCount": "+थप {count} खुला अर्डरहरू", - "lockTerminal": "टर्मिनल लक गर्नुहोस्" + "lockTerminal": "टर्मिनल लक गर्नुहोस्", + "selectOpenShift": "बन्द गर्ने सिफ्ट", + "xReportShiftFilterLabel": "सिफ्टको दायरा", + "xReportAllShifts": "सबै सिफ्टहरू (पूरा दिन)" }, "printTest": { "amt": "रकम", @@ -2569,7 +2572,13 @@ "autoLockLabel": "निष्क्रिय अटो-लक", "autoLockHint": "निष्क्रिय रहँदा पीओएस टर्मिनल स्वतः लक गर्नुहोस्", "autoLockOff": "बन्द", - "autoLockMinutesOption": "{count} मिनेट" + "autoLockMinutesOption": "{count} मिनेट", + "requireOpenShift": "नगदका लागि खुला सिफ्ट अनिवार्य गर्नुहोस्", + "requireOpenShiftHint": "सक्रिय सिफ्ट नखुलेसम्म नगद भुक्तानी, नगद फिर्ता र ड्रअरका कारोबारहरू रोक्नुहोस्।", + "oneShiftPerStaff": "प्रति कर्मचारी एक सिफ्ट", + "oneShiftPerStaffHint": "प्रत्येक कर्मचारीलाई आफ्नो क्यास सिफ्ट स्वतन्त्र रूपमा खोल्न र बन्द गर्न अनुमति दिनुहोस्।", + "oneShiftPerStaffSharedDrawerWarning": "नोट: यदि दुई एकैसाथ चलेका सिफ्टहरूले एउटै भौतिक क्यास ड्रअर प्रयोग गर्छन् भने व्यक्तिगत नगद गणना भौतिक रूपमा अलग हुनेछैन।", + "oneShiftPerStaffDisableBlocked": "धेरै सिफ्टहरू खुला हुँदा प्रति कर्मचारी एक सिफ्ट बन्द गर्न सकिँदैन। पहिले अतिरिक्त सिफ्टहरू बन्द गर्नुहोस्।" }, "setup": { "alreadyCompleted": "यस स्थापनामा सेटअप पहिले नै पूरा भइसकेको छ। लगइन गर्न रिडिरेक्ट गर्दै...", diff --git a/frontend/src/lib/i18n/messages/nl.json b/frontend/src/lib/i18n/messages/nl.json index 823d87d34..6552fcfc3 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# open bestelling wordt meegenomen} other {# open bestellingen worden meegenomen}}", "shiftCloseUnpaidWarningHint": "Deze bestellingen blijven open voor de volgende dienst. Bij het sluiten van deze dienst worden alleen de contante betalingen verrekend die tijdens deze dienst al zijn ontvangen.", "shiftCloseUnpaidMoreCount": "+{count} extra open bestellingen", - "lockTerminal": "Terminal vergrendelen" + "lockTerminal": "Terminal vergrendelen", + "selectOpenShift": "Te sluiten dienst", + "xReportShiftFilterLabel": "Dienstbereik", + "xReportAllShifts": "Alle diensten (hele dag)" }, "printTest": { "amt": "Bedrag", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Automatisch vergrendelen bij inactiviteit", "autoLockHint": "Vergrendel de kassa automatisch na inactiviteit", "autoLockOff": "Uit", - "autoLockMinutesOption": "{count} min." + "autoLockMinutesOption": "{count} min.", + "requireOpenShift": "Geopende dienst vereisen voor contant geld", + "requireOpenShiftHint": "Blokkeer contante betalingen, contante terugbetalingen en kassaladebewegingen tenzij er een dienst geopend is.", + "oneShiftPerStaff": "Één dienst per medewerker", + "oneShiftPerStaffHint": "Sta elke medewerker toe om zelfstandig een eigen kassadienst te openen en te sluiten.", + "oneShiftPerStaffSharedDrawerWarning": "Let op: Als twee gelijktijdige diensten één fysieke kassalade delen, zijn de individuele kastellingen niet fysiek gescheiden.", + "oneShiftPerStaffDisableBlocked": "Kan één dienst per medewerker niet uitschakelen terwijl er meerdere diensten geopend zijn. Sluit eerst de extra diensten." }, "setup": { "alreadyCompleted": "De installatie is bij deze installatie al voltooid. Omleiden naar inloggen…", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 455b670b2..d421d6598 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# pedido aberto será transferido} other {# pedidos abertos serão transferidos}}", "shiftCloseUnpaidWarningHint": "Estes pedidos permanecem abertos para o próximo turno. Fechar este turno concilia apenas os pagamentos em dinheiro já recebidos durante este turno.", "shiftCloseUnpaidMoreCount": "+{count} outros pedidos abertos", - "lockTerminal": "Bloquear terminal" + "lockTerminal": "Bloquear terminal", + "selectOpenShift": "Turno a fechar", + "xReportShiftFilterLabel": "Escopo do turno", + "xReportAllShifts": "Todos os turnos (dia inteiro)" }, "printTest": { "amt": "Total", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Bloqueio automático por inatividade", "autoLockHint": "Bloquear automaticamente o terminal PDV após inatividade", "autoLockOff": "Desativado", - "autoLockMinutesOption": "{count} min." + "autoLockMinutesOption": "{count} min.", + "requireOpenShift": "Exigir turno aberto para dinheiro", + "requireOpenShiftHint": "Bloquear pagamentos em dinheiro, reembolsos em dinheiro e movimentos de caixa sem um turno aberto.", + "oneShiftPerStaff": "Um turno por funcionário", + "oneShiftPerStaffHint": "Permitir que cada funcionário abra e feche o seu próprio turno de caixa de forma independente.", + "oneShiftPerStaffSharedDrawerWarning": "Nota: Se dois turnos simultâneos partilharem uma única gaveta física, as contagens individuais de dinheiro não estarão separadas fisicamente.", + "oneShiftPerStaffDisableBlocked": "Não é possível desativar um turno por funcionário enquanto vários turnos estiverem abertos. Feche primeiro os turnos adicionais." }, "setup": { "alreadyCompleted": "A configuração já foi concluída nesta instalação. Redirecionando para o login…", diff --git a/frontend/src/lib/i18n/messages/ru.json b/frontend/src/lib/i18n/messages/ru.json index cbffcbb48..aadd6d495 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# открытый заказ перейдет в следующую смену} other {# открытых заказов перейдут в следующую смену}}", "shiftCloseUnpaidWarningHint": "Эти заказы остаются открытыми для следующей смены. Закрытие этой смены сверяет только наличные платежи, уже принятые в течение этой смены.", "shiftCloseUnpaidMoreCount": "+ещё {count} открытых заказов", - "lockTerminal": "Заблокировать терминал" + "lockTerminal": "Заблокировать терминал", + "selectOpenShift": "Смена для закрытия", + "xReportShiftFilterLabel": "Охват смены", + "xReportAllShifts": "Все смены (весь день)" }, "printTest": { "amt": "Сумма", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Автоблокировка при простое", "autoLockHint": "Автоматически блокировать POS-терминал при бездействии", "autoLockOff": "Выкл.", - "autoLockMinutesOption": "{count} мин." + "autoLockMinutesOption": "{count} мин.", + "requireOpenShift": "Требовать открытую смену для наличных", + "requireOpenShiftHint": "Блокировать оплату наличными, возвраты наличными и движения по кассе, если смена не открыта.", + "oneShiftPerStaff": "Одна смена на сотрудника", + "oneShiftPerStaffHint": "Разрешить каждому сотруднику самостоятельно открывать и закрывать собственную кассовую смену.", + "oneShiftPerStaffSharedDrawerWarning": "Примечание: Если две одновременные смены используют один физический денежный ящик, индивидуальный подсчет наличных не будет физически разделен.", + "oneShiftPerStaffDisableBlocked": "Нельзя отключить режим одной смены на сотрудника, пока открыто несколько смен. Сначала закройте лишние смены." }, "setup": { "alreadyCompleted": "Настройка этой установки уже завершена. Перенаправление на вход…", diff --git a/frontend/src/lib/i18n/messages/sq.json b/frontend/src/lib/i18n/messages/sq.json index 83f404f06..e2000832f 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# porosi e hapur do të bartet} other {# porosi të hapura do të barten}}", "shiftCloseUnpaidWarningHint": "Këto porosi mbeten të hapura për turnin tjetër. Mbyllja e këtij turni rakordon vetëm pagesat me para në dorë të arkëtuara tashmë gjatë këtij turni.", "shiftCloseUnpaidMoreCount": "+{count} porosi të tjera të hapura", - "lockTerminal": "Kyç terminalin" + "lockTerminal": "Kyç terminalin", + "selectOpenShift": "Turni për t’u mbyllur", + "xReportShiftFilterLabel": "Fusha e turnit", + "xReportAllShifts": "Të gjitha turnet (gjithë dita)" }, "printTest": { "amt": "Shuma", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Kyçje automatike në pasivitet", "autoLockHint": "Kyç automatikisht terminalin POS pas mosaktivitetit", "autoLockOff": "Joaktiv", - "autoLockMinutesOption": "{count} min." + "autoLockMinutesOption": "{count} min.", + "requireOpenShift": "Kërko turn të hapur për para në dorë", + "requireOpenShiftHint": "Blloko pagesat me para në dorë, rimbursimet me para në dorë dhe lëvizjet e arkës nëse nuk ka një turn aktiv të hapur.", + "oneShiftPerStaff": "Një turn për çdo punonjës", + "oneShiftPerStaffHint": "Lejo çdo punonjës të hapë dhe të mbyllë në mënyrë të pavarur turnin e vet të arkës.", + "oneShiftPerStaffSharedDrawerWarning": "Shënim: Nëse dy turne të njëkohshme ndajnë të njëjtin sirtar fizik të arkës, numërimet individuale të parave nuk do të jenë të ndara fizikisht.", + "oneShiftPerStaffDisableBlocked": "Nuk mund të çaktivizohet një turn për punonjës ndërkohë që disa turne janë të hapura. Mbyllni fillimisht turnet shtesë." }, "setup": { "alreadyCompleted": "Konfigurimi në këtë instalim është përfunduar. Po ridrejtohemi te hyrja...", diff --git a/frontend/src/lib/i18n/messages/th.json b/frontend/src/lib/i18n/messages/th.json index 1c4f3f635..f877a4c7e 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {ออเดอร์ที่เปิดอยู่ # รายการจะส่งต่อไปกะถัดไป} other {ออเดอร์ที่เปิดอยู่ # รายการจะส่งต่อไปกะถัดไป}}", "shiftCloseUnpaidWarningHint": "ออเดอร์เหล่านี้จะยังคงเปิดอยู่สำหรับกะถัดไป การปิดกะนี้จะกระทบยอดเฉพาะเงินสดที่รับชำระแล้วในระหว่างกะนี้เท่านั้น", "shiftCloseUnpaidMoreCount": "+อีก {count} ออเดอร์ที่เปิดอยู่", - "lockTerminal": "ล็อกเครื่อง POS" + "lockTerminal": "ล็อกเครื่อง POS", + "selectOpenShift": "กะที่จะปิด", + "xReportShiftFilterLabel": "ขอบเขตกะ", + "xReportAllShifts": "ทุกกะ (ทั้งวัน)" }, "printTest": { "amt": "จำนวนเงิน", @@ -2569,7 +2572,13 @@ "autoLockLabel": "ล็อกอัตโนมัติเมื่อไม่มีการใช้งาน", "autoLockHint": "ล็อกหน้าจอ POS อัตโนมัติเมื่อไม่มีการใช้งาน", "autoLockOff": "ปิด", - "autoLockMinutesOption": "{count} นาที" + "autoLockMinutesOption": "{count} นาที", + "requireOpenShift": "ต้องเปิดกะก่อนรับเงินสด", + "requireOpenShiftHint": "บล็อกการรับชำระด้วยเงินสด การคืนเงินสด และความเคลื่อนไหวของลิ้นชักเว้นแต่จะเปิดกะอยู่", + "oneShiftPerStaff": "หนึ่งกะต่อพนักงานหนึ่งคน", + "oneShiftPerStaffHint": "อนุญาตให้พนักงานแต่ละคนเปิดและปิดกะเงินสดของตนเองได้อย่างอิสระ", + "oneShiftPerStaffSharedDrawerWarning": "หมายเหตุ: หากสองกะที่เปิดพร้อมกันใช้ลิ้นชักเก็บเงินจริงร่วมกัน ยอดนับเงินสดของแต่ละกะจะไม่แยกจากกันทางกายภาพ", + "oneShiftPerStaffDisableBlocked": "ไม่สามารถปิดใช้งานหนึ่งกะต่อพนักงานในขณะที่มีหลายกะเปิดอยู่ กรุณาปิดกะอื่นก่อน" }, "setup": { "alreadyCompleted": "ตั้งค่าการติดตั้งนี้เสร็จสมบูรณ์แล้ว กำลังกลับไปหน้าเข้าสู่ระบบ…", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 9090b199a..14d4ed0da 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# açık sipariş sonraki vardiyaya devredilecek} other {# açık sipariş sonraki vardiyaya devredilecek}}", "shiftCloseUnpaidWarningHint": "Bu siparişler bir sonraki vardiya için açık kalır. Bu vardiyayı kapatmak yalnızca bu vardiya sırasında tahsil edilmiş nakit ödemeleri mutabakatlandırır.", "shiftCloseUnpaidMoreCount": "+{count} açık sipariş daha", - "lockTerminal": "Terminali Kilitle" + "lockTerminal": "Terminali Kilitle", + "selectOpenShift": "Kapatılacak vardiya", + "xReportShiftFilterLabel": "Vardiya kapsamı", + "xReportAllShifts": "Tüm vardiyalar (tüm gün)" }, "printTest": { "amt": "Tutar", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Boşta Otomatik Kilitleme", "autoLockHint": "Hareketsizlik sonrasında POS terminalini otomatik kilitle", "autoLockOff": "Kapalı", - "autoLockMinutesOption": "{count} dk." + "autoLockMinutesOption": "{count} dk.", + "requireOpenShift": "Nakit için Açık Vardiya Zorunlu", + "requireOpenShiftHint": "Aktif bir vardiya açık olmadıkça nakit ödemeleri, nakit iadeleri ve kasa hareketlerini engelle.", + "oneShiftPerStaff": "Personel başına bir vardiya", + "oneShiftPerStaffHint": "Her personelin kendi kasa vardiyasını bağımsız olarak açıp kapatmasına izin ver.", + "oneShiftPerStaffSharedDrawerWarning": "Not: İki eşzamanlı vardiya tek bir fiziksel nakit çekmecesini paylaşıyorsa, bireysel çekmece nakit sayımları fiziksel olarak ayrılmaz.", + "oneShiftPerStaffDisableBlocked": "Birden fazla vardiya açıkken personel başına bir vardiya devre dışı bırakılamaz. Önce diğer vardiyaları kapatın." }, "setup": { "alreadyCompleted": "Kurulum bu cihazda zaten tamamlandı. Giriş ekranına yönlendiriliyorsunuz…", diff --git a/frontend/src/lib/i18n/messages/ur.json b/frontend/src/lib/i18n/messages/ur.json index 30a1f4bfe..0ebf30248 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# کھلا آرڈر اگلی شفٹ میں منتقل ہوگا} other {# کھلے آرڈرز اگلی شفٹ میں منتقل ہوں گے}}", "shiftCloseUnpaidWarningHint": "یہ آرڈرز اگلی شفٹ کے لیے کھلے رہیں گے۔ اس شفٹ کو بند کرنے سے صرف وہ نقد ادائیگیاں ملائی جاتی ہیں جو اس شفٹ کے دوران وصول ہو چکی ہیں۔", "shiftCloseUnpaidMoreCount": "+{count} مزید کھلے آرڈرز", - "lockTerminal": "ٹرمینل لاک کریں" + "lockTerminal": "ٹرمینل لاک کریں", + "selectOpenShift": "بند کرنے کے لیے شفٹ", + "xReportShiftFilterLabel": "شفٹ کا دائرہ", + "xReportAllShifts": "تمام شفٹیں (پورا دن)" }, "printTest": { "amt": "رقم", @@ -2569,7 +2572,13 @@ "autoLockLabel": "غیر فعالیت پر آٹو لاک", "autoLockHint": "غیر فعال رہنے پر پی او ایس ٹرمینل خودکار طور پر لاک کریں", "autoLockOff": "بند", - "autoLockMinutesOption": "{count} منٹ" + "autoLockMinutesOption": "{count} منٹ", + "requireOpenShift": "نقدی کے لیے کھلی شفٹ لازمی کریں", + "requireOpenShiftHint": "جب تک کوئی فعال شفٹ کھلی نہ ہو نقد ادائیگیوں، نقد واپسی اور دراز کی نقل و حرکت کو روکیں۔", + "oneShiftPerStaff": "فی اسٹاف ممبر ایک شفٹ", + "oneShiftPerStaffHint": "ہر اسٹاف ممبر کو اپنی کیش شفٹ آزادانہ طور پر کھولنے اور بند کرنے کی اجازت دیں۔", + "oneShiftPerStaffSharedDrawerWarning": "نوٹ: اگر دو بیک وقت شفٹیں ایک ہی فزیکل کیش دراز استعمال کرتی ہیں تو انفرادی نقد گنتی فزیکل طور پر الگ نہیں ہوگی۔", + "oneShiftPerStaffDisableBlocked": "جب ایک سے زیادہ شفٹیں کھلی ہوں تو فی اسٹاف ایک شفٹ کو غیر فعال نہیں کیا جا سکتا۔ پہلے اضافی شفٹیں بند کریں۔" }, "setup": { "alreadyCompleted": "اس انسٹال کا سیٹ اپ پہلے ہی مکمل ہو چکا ہے۔ سائن ان پر بھیج رہا ہے…", diff --git a/frontend/src/lib/i18n/messages/vi.json b/frontend/src/lib/i18n/messages/vi.json index f61155a61..2da53d93e 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# đơn đang mở sẽ chuyển sang ca tiếp theo} other {# đơn đang mở sẽ chuyển sang ca tiếp theo}}", "shiftCloseUnpaidWarningHint": "Các đơn này vẫn mở cho ca tiếp theo. Việc đóng ca này chỉ đối soát các khoản tiền mặt đã thu trong ca này.", "shiftCloseUnpaidMoreCount": "+{count} đơn đang mở khác", - "lockTerminal": "Khóa máy POS" + "lockTerminal": "Khóa máy POS", + "selectOpenShift": "Ca cần đóng", + "xReportShiftFilterLabel": "Phạm vi ca", + "xReportAllShifts": "Tất cả các ca (cả ngày)" }, "printTest": { "amt": "Số tiền", @@ -2569,7 +2572,13 @@ "autoLockLabel": "Tự động khóa khi nghỉ", "autoLockHint": "Tự động khóa máy POS sau thời gian không hoạt động", "autoLockOff": "Tắt", - "autoLockMinutesOption": "{count} phút" + "autoLockMinutesOption": "{count} phút", + "requireOpenShift": "Yêu cầu mở ca cho giao dịch tiền mặt", + "requireOpenShiftHint": "Chặn thanh toán tiền mặt, hoàn tiền mặt và thu chi két tiền trừ khi có ca đang mở.", + "oneShiftPerStaff": "Mỗi nhân viên một ca", + "oneShiftPerStaffHint": "Cho phép mỗi nhân viên tự mở và đóng ca thu ngân của riêng mình một cách độc lập.", + "oneShiftPerStaffSharedDrawerWarning": "Lưu ý: Nếu hai ca song song dùng chung một ngăn kéo tiền vật lý, số tiền đếm riêng của từng ca sẽ không tách biệt về mặt vật lý.", + "oneShiftPerStaffDisableBlocked": "Không thể tắt chế độ mỗi nhân viên một ca khi đang có nhiều ca mở. Vui lòng đóng các ca phụ trước." }, "setup": { "alreadyCompleted": "Bản cài đặt này đã hoàn tất thiết lập. Đang chuyển đến đăng nhập…", diff --git a/frontend/src/lib/i18n/messages/zh-tw.json b/frontend/src/lib/i18n/messages/zh-tw.json index 76b900b36..5bceb2247 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# 筆未結訂單將結轉至下一班次} other {# 筆未結訂單將結轉至下一班次}}", "shiftCloseUnpaidWarningHint": "這些訂單將保持開啟並交接給下一班次。關閉本班次僅核對本班次期間已收取的現金付款。", "shiftCloseUnpaidMoreCount": "另有 +{count} 筆未結訂單", - "lockTerminal": "鎖定終端機" + "lockTerminal": "鎖定終端機", + "selectOpenShift": "要關閉的班次", + "xReportShiftFilterLabel": "班次範圍", + "xReportAllShifts": "所有班次(全天)" }, "printTest": { "amt": "金額", @@ -2569,7 +2572,13 @@ "autoLockLabel": "閒置自動鎖定", "autoLockHint": "閒置一段時間後自動鎖定 POS 終端機", "autoLockOff": "關閉", - "autoLockMinutesOption": "{count} 分鐘" + "autoLockMinutesOption": "{count} 分鐘", + "requireOpenShift": "現金交易需開啟班次", + "requireOpenShiftHint": "未開啟活動班次時,禁止現金收款、現金退款和錢箱存取操作。", + "oneShiftPerStaff": "每位員工獨立班次", + "oneShiftPerStaffHint": "允許每位員工獨立開啟與關閉自己的收銀班次。", + "oneShiftPerStaffSharedDrawerWarning": "注意:若兩個並行班次共用同一個實體錢箱,各自的現金清點將無法在物理上分開。", + "oneShiftPerStaffDisableBlocked": "有多個班次處於開啟狀態時,無法停用「每位員工獨立班次」。請先關閉多餘的班次。" }, "setup": { "alreadyCompleted": "此安裝已完成設定。正在跳轉到登入…", diff --git a/frontend/src/lib/i18n/messages/zh.json b/frontend/src/lib/i18n/messages/zh.json index b7b033200..ebe67f798 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -1209,7 +1209,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# 笔未结订单将结转至下一班次} other {# 笔未结订单将结转至下一班次}}", "shiftCloseUnpaidWarningHint": "这些订单将保持开启并交接给下一班次。关闭本班次仅核对本班次期间已收取的现金付款。", "shiftCloseUnpaidMoreCount": "另有 +{count} 笔未结订单", - "lockTerminal": "锁定终端" + "lockTerminal": "锁定终端", + "selectOpenShift": "要关闭的班次", + "xReportShiftFilterLabel": "班次范围", + "xReportAllShifts": "所有班次(全天)" }, "printTest": { "amt": "金额", @@ -2569,7 +2572,13 @@ "autoLockLabel": "空闲自动锁定", "autoLockHint": "闲置一段时间后自动锁定 POS 终端", "autoLockOff": "关闭", - "autoLockMinutesOption": "{count} 分钟" + "autoLockMinutesOption": "{count} 分钟", + "requireOpenShift": "现金交易需开启班次", + "requireOpenShiftHint": "未开启活动班次时,禁止现金收款、现金退款和钱箱存取操作。", + "oneShiftPerStaff": "每位员工独立班次", + "oneShiftPerStaffHint": "允许每位员工独立开启和关闭自己的收银班次。", + "oneShiftPerStaffSharedDrawerWarning": "注意:如果两个并行班次共用同一个实体钱箱,各自的现金清点将无法在物理上分开。", + "oneShiftPerStaffDisableBlocked": "有多个班次处于开启状态时,无法关闭“每位员工独立班次”。请先关闭多余的班次。" }, "setup": { "alreadyCompleted": "此安装已完成设置。正在跳转到登录…", diff --git a/main/db.ts b/main/db.ts index e47afba31..7800546ad 100644 --- a/main/db.ts +++ b/main/db.ts @@ -7774,6 +7774,18 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ `); }, }, + { + version: 120, + name: 'add_per_staff_cash_sessions', + up: () => { + db.exec(` + DROP INDEX IF EXISTS cash_sessions_one_open; + CREATE UNIQUE INDEX IF NOT EXISTS cash_sessions_one_open_per_user + ON cash_sessions(opened_by) WHERE status = 'open'; + `); + insertSettingIfMissing('one_shift_per_staff', 'false'); + }, + }, ]; function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void { @@ -8551,6 +8563,7 @@ function seedInstallDefaults(): void { // Shift enforcement ships default-off (#279, approach A): sales work with // no open session unless the owner opts in. Stale auto-close threshold in days. insert('require_open_shift', 'false'); + insert('one_shift_per_staff', 'false'); insert('stale_session_days', '7'); insert('address', ''); insert('phone', ''); diff --git a/main/routes/bills.ts b/main/routes/bills.ts index 6f2160df9..cd590027e 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -3782,8 +3782,9 @@ function applyPaymentBatch( requireOpenSessionForCashTender( db, prepared.map((line) => line.payment), + idempotencyUserId, ); - const activeSessionId = getOpenSession(db)?.id ?? NO_CASH_SESSION_ID; + const activeSessionId = getOpenSession(db, idempotencyUserId)?.id ?? NO_CASH_SESSION_ID; const currency = getTenantCurrency(); const minorFactor = getCurrencyMinorUnitFactor(currency); const totalAppliedCents = prepared.reduce((sum, line) => sum + line.amountCents, 0); @@ -5943,7 +5944,7 @@ router.patch( } effectiveSessionId = session.id; } else { - const openSession = getOpenSession(db); + const openSession = getOpenSession(db, effectiveActorUserId); if (!openSession) { throw paymentValidationError( 'Cannot correct payment method: no open cash shift', @@ -6020,7 +6021,7 @@ router.patch( ); } } else { - const openSession = getOpenSession(db); + const openSession = getOpenSession(db, effectiveActorUserId); const sibTime = String( sibLine.timestamp || sibling.paid_at || sibling.created_at || '', ); diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index 04528b8d7..957f22ac2 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -44,7 +44,10 @@ import { import { requirePermission } from '../services/authorization'; import { nextZNumber } from '../db'; import { getTenantCurrency } from '../services/refund'; -import { openSessionBlockingDayClose } from '../services/cash-day-close-guards'; +import { + openSessionBlockingDayClose, + openSessionsBlockingDayClose, +} from '../services/cash-day-close-guards'; import { getOpenSession, NO_CASH_SESSION_ID, @@ -943,10 +946,34 @@ router.post( ); const reason = validateMovementReason(body.reason, movementType !== 'opening_float'); const createdBy = String((req as any).user?.userId || ''); + const role = String((req as any).user?.role || ''); if (!createdBy) throw httpError('Authentication required', 401); const db = getDatabase(); - // Shift enforcement (#279): every movement touches the drawer. - requireOpenSessionForCash(db); + let targetSessionId: number; + if (body.cash_session_id !== undefined && body.cash_session_id !== null) { + const requestedSessionId = Number(body.cash_session_id); + if (!Number.isInteger(requestedSessionId) || requestedSessionId <= 0) { + throw httpError('cash_session_id must be a positive integer', 400); + } + const targetSession = db + .prepare(`SELECT id, opened_by FROM cash_sessions WHERE id = ? AND status = 'open'`) + .get(requestedSessionId) as { id: number; opened_by: string } | undefined; + if (!targetSession) { + throw httpError('Open shift not found', 404, 'cash_session_not_found'); + } + if (role !== 'owner' && role !== 'manager' && targetSession.opened_by !== createdBy) { + throw httpError( + 'Only an owner or manager can record movements for another staff shift', + 403, + 'session_owner_required', + ); + } + targetSessionId = targetSession.id; + } else { + // Shift enforcement (#279): every movement touches the drawer. + requireOpenSessionForCash(db, createdBy); + targetSessionId = getOpenSession(db, createdBy)?.id ?? NO_CASH_SESSION_ID; + } const id = withTxn(() => { let movementId: number; try { @@ -956,7 +983,7 @@ router.post( amountCents, reason, createdBy, - cashSessionId: getOpenSession(db)?.id ?? NO_CASH_SESSION_ID, + cashSessionId: targetSessionId, }); } catch (error: any) { if (error.code === 'day_closed') { @@ -1125,7 +1152,8 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon if (!closedBy) throw httpError('Authentication required', 401); const db = getDatabase(); - const blockingSession = openSessionBlockingDayClose(db, businessDate); + const blockingSessions = openSessionsBlockingDayClose(db, businessDate); + const blockingSession = blockingSessions[0]; if (blockingSession) { throw Object.assign( httpError( @@ -1139,6 +1167,12 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon opened_by_name: blockingSession.opened_by_name, opened_at: blockingSession.opened_at, }, + blockingShifts: blockingSessions.map((s) => ({ + id: s.id, + opened_by: s.opened_by, + opened_by_name: s.opened_by_name, + opened_at: s.opened_at, + })), }, ); } @@ -1319,6 +1353,9 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon if (error?.blockingShift) { (payload as { blocking_shift?: unknown }).blocking_shift = error.blockingShift; } + if (error?.blockingShifts) { + (payload as { blocking_shifts?: unknown }).blocking_shifts = error.blockingShifts; + } res.status(status).json(payload); } }); diff --git a/main/routes/cash-sessions.ts b/main/routes/cash-sessions.ts index 74a652236..eb5a27a06 100644 --- a/main/routes/cash-sessions.ts +++ b/main/routes/cash-sessions.ts @@ -25,7 +25,12 @@ import { import { requirePermission } from '../services/authorization'; import { closedDayExists } from '../services/cash-drawer-movements'; import { findUnpaidInShiftOrders, sessionBusinessDate } from '../services/cash-day-close-guards'; -import { getOpenSession, type CashSessionRow } from '../services/shift-session-gate'; +import { + getOpenSession, + isPerStaffShiftsEnabled, + listOpenSessions, + type CashSessionRow, +} from '../services/shift-session-gate'; import { computePeriodAggregates, sessionExpectedCash, @@ -252,8 +257,14 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: } } } - if (getOpenSession(db)) + const perStaffShifts = isPerStaffShiftsEnabled(db); + if (perStaffShifts) { + if (getOpenSession(db, openedBy)) { + throw httpError('You already have an open shift', 409, 'session_already_open'); + } + } else if (listOpenSessions(db).length > 0) { throw httpError('A shift is already open', 409, 'session_already_open'); + } const openBusinessDate = localDateInTimezone( new Date(), tenantTimezone(), @@ -272,20 +283,25 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: // Keep day close reconciling the same physical drawer: mirror the // float as the day's opening_float movement. Session math ignores // in-window float movements (openingFloatOverride), so this cannot - // double-count there. One movement per day (schema constraint): a - // second shift reuses the first float for day-close purposes. + // double-count there. When per-staff shifts are enabled, concurrent + // shifts on the same business date sum their opening floats into the + // day's opening_float row so day close equals the sum of all shifts. if (openingFloatCents > 0) { const existingFloat = db .prepare( `SELECT id FROM cash_drawer_movements WHERE business_date = ? AND movement_type = 'opening_float' AND voided_at IS NULL LIMIT 1`, ) - .get(openBusinessDate); + .get(openBusinessDate) as { id: number } | undefined; if (!existingFloat) { db.prepare(` INSERT INTO cash_drawer_movements ( business_date, movement_type, amount_cents, reason, created_by, created_at, cash_session_id ) VALUES (?, 'opening_float', ?, 'Shift opening float', ?, ?, ?) `).run(openBusinessDate, openingFloatCents, openedBy, openedAt, sessionId); + } else if (perStaffShifts) { + db.prepare( + `UPDATE cash_drawer_movements SET amount_cents = amount_cents + ? WHERE id = ?`, + ).run(openingFloatCents, existingFloat.id); } } const openedSession = db.prepare(`SELECT * FROM cash_sessions WHERE id = ?`).get(sessionId); @@ -329,18 +345,67 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: } }); +router.get('/open', requirePermission('cash.shifts.view'), (req: Request, res: Response) => { + try { + const db = getDatabase(); + const asOf = now(); + const role = actorRole(req); + const callerId = actorId(req); + const allOpen = listOpenSessions(db); + const visible = + role === 'owner' || role === 'manager' + ? allOpen + : allOpen.filter((s) => s.opened_by === callerId); + res.json({ + sessions: visible.map((session) => ({ + ...session, + expected_cash_cents: sessionExpectedCash(db, session, asOf), + })), + one_shift_per_staff: isPerStaffShiftsEnabled(db), + }); + } catch (error: unknown) { + const status = errorStatus(error); + res.status(status).json({ + error: + status >= 500 + ? 'Failed to list open shifts' + : errorMessage(error, 'Failed to list open shifts'), + }); + } +}); + router.get( '/current/close-preview', requirePermission('cash.shifts.close'), (req: Request, res: Response) => { try { const db = getDatabase(); - const session = getOpenSession(db); + const callerId = actorId(req); + const role = actorRole(req); + let session: CashSessionRow | undefined; + if (req.query.session_id !== undefined && String(req.query.session_id).trim() !== '') { + const requestedId = Number(req.query.session_id); + if (!Number.isInteger(requestedId) || requestedId <= 0) { + throw httpError('Invalid session id', 400, 'session_id_invalid'); + } + session = db + .prepare(`SELECT * FROM cash_sessions WHERE id = ? AND status = 'open'`) + .get(requestedId) as CashSessionRow | undefined; + } else { + session = getOpenSession(db, callerId); + } if (!session) { return res .status(404) .json({ error: 'No open shift', display_code: 'session_unavailable' }); } + if (role !== 'owner' && role !== 'manager' && session.opened_by !== callerId) { + return res.status(403).json({ + error: 'Cashiers can only close their own shift', + code: 'session_owner_required', + display_code: 'session_owner_required', + }); + } const asOf = now(); const unpaid = findUnpaidInShiftOrders(db, session, asOf); res.json({ @@ -365,7 +430,35 @@ router.get( router.get('/current', requirePermission('cash.shifts.view'), (req: Request, res: Response) => { try { const db = getDatabase(); - const session = getOpenSession(db); + const callerId = actorId(req); + const role = actorRole(req); + let session: CashSessionRow | undefined; + if (req.query.session_id !== undefined && String(req.query.session_id).trim() !== '') { + const requestedId = Number(req.query.session_id); + if (!Number.isInteger(requestedId) || requestedId <= 0) { + throw httpError('Invalid session id', 400, 'session_id_invalid'); + } + session = db + .prepare(`SELECT * FROM cash_sessions WHERE id = ? AND status = 'open'`) + .get(requestedId) as CashSessionRow | undefined; + if (session && role !== 'owner' && role !== 'manager' && session.opened_by !== callerId) { + return res.status(403).json({ + error: 'Cashiers can only view their own shift', + code: 'session_owner_required', + }); + } + } else if (typeof req.query.user_id === 'string' && req.query.user_id.trim() !== '') { + const requestedUserId = req.query.user_id.trim(); + if (role !== 'owner' && role !== 'manager' && requestedUserId !== callerId) { + return res.status(403).json({ + error: 'Cashiers can only view their own shift', + code: 'session_owner_required', + }); + } + session = getOpenSession(db, requestedUserId); + } else { + session = getOpenSession(db, callerId); + } // Documented contract (docs/reference/api.md): 200 with `null` when no // shift is open. A 404 here logged a failed request on every POS mount. if (!session) return res.json(null); @@ -411,9 +504,9 @@ router.post('/:id/close', requirePermission('cash.shifts.close'), (req: Request, if (!session) throw httpError('Shift not found', 404, 'session_unavailable'); if (session.status !== 'open') throw httpError('Shift is already closed', 409, 'session_already_closed'); - // Own-session rule: cashiers close only the shift they opened; - // manager/owner may force-close anyone's (recorded via closed_by). - if (role === 'cashier' && session.opened_by !== closer) { + // Own-session rule: non-owner/manager roles close only the shift they opened; + // manager/owner may close anyone's (recorded via closed_by). + if (role !== 'owner' && role !== 'manager' && session.opened_by !== closer) { throw httpError('Cashiers can only close their own shift', 403, 'session_owner_required'); } const closedSession = closeSessionTxn(db, session, countedCashCents, closer, notes); diff --git a/main/routes/expenses.ts b/main/routes/expenses.ts index e6e31be5a..47f146d03 100644 --- a/main/routes/expenses.ts +++ b/main/routes/expenses.ts @@ -38,9 +38,11 @@ interface AuthedRequest extends Request { function sendError(res: Response, error: unknown): void { const details = error as { statusCode?: unknown; message?: unknown; code?: unknown }; - const statusCode = Number.isInteger(details.statusCode) ? details.statusCode as number : 500; + const statusCode = Number.isInteger(details.statusCode) ? (details.statusCode as number) : 500; if (statusCode >= 500) console.error('[API] Internal error:', error); - const body: Record = { error: statusCode >= 500 ? 'Internal server error' : details.message }; + const body: Record = { + error: statusCode >= 500 ? 'Internal server error' : details.message, + }; if (typeof details.code === 'string' && details.code) body.code = details.code; res.status(statusCode).json(body); } @@ -101,7 +103,10 @@ function queryString(value: unknown, field: string): string | undefined { function listFiltersFromQuery(query: Request['query']): ExpenseListFilters { const status = queryString(query.status, 'status'); if (status !== undefined && !(EXPENSE_STATUS_FILTERS as readonly string[]).includes(status)) { - throw new ExpenseServiceError(400, `status must be one of: ${EXPENSE_STATUS_FILTERS.join(', ')}`); + throw new ExpenseServiceError( + 400, + `status must be one of: ${EXPENSE_STATUS_FILTERS.join(', ')}`, + ); } return { from: queryString(query.from, 'from') ?? null, @@ -117,13 +122,19 @@ function listFiltersFromQuery(query: Request['query']): ExpenseListFilters { /** Client mutations must carry the header: a retry without it is a new expense. */ function requireIdempotencyKey(req: Request): string { const key = normalizeIdempotencyKey(req.get('Idempotency-Key')); - if (!key) throw new ExpenseServiceError(400, 'Idempotency-Key header is required for this operation', 'idempotency_key_required'); + if (!key) + throw new ExpenseServiceError( + 400, + 'Idempotency-Key header is required for this operation', + 'idempotency_key_required', + ); return key; } router.get('/categories', requirePermission('expenses.view'), (req: Request, res: Response) => { try { - const includeInactive = req.query.include_inactive === 'true' || req.query.include_inactive === '1'; + const includeInactive = + req.query.include_inactive === 'true' || req.query.include_inactive === '1'; const page = listExpenseCategories(getDatabase(), { includeInactive }); res.json({ categories: page.categories, truncated: page.truncated }); } catch (error: unknown) { @@ -163,9 +174,9 @@ router.patch('/categories/:id', requireExpenseWrite, (req: Request, res: Respons } }); -router.get('/context', requirePermission('expenses.view'), (_req: Request, res: Response) => { +router.get('/context', requirePermission('expenses.view'), (req: Request, res: Response) => { try { - res.json(getExpenseContext(getDatabase())); + res.json(getExpenseContext(getDatabase(), String((req as AuthedRequest).user?.userId || ''))); } catch (error: unknown) { sendError(res, error); } @@ -258,39 +269,50 @@ router.post('/:id/replace', requireExpenseWrite, (req: Request, res: Response) = }); /** Amounts settle against the immutable ledger; a cash entry also writes a linked Pay Out. */ -router.post('/:id/payments', requireExpenseAuthority('expenses.pay'), (req: Request, res: Response) => { - try { - const body = req.body || {}; - requireCashDrawerAuthority(req, normalizeExpensePaymentMethod(body.method) === 'cash'); - const outcome = recordExpensePayment(getDatabase(), String(req.params.id), { - ...body, - actorUserId: actorId(req as AuthedRequest), - idempotencyKey: requireIdempotencyKey(req), - }); - if (outcome.replayed) res.set('Idempotent-Replay', 'true'); - res.status(outcome.status).json(outcome.body); - } catch (error: unknown) { - sendError(res, error); - } -}); +router.post( + '/:id/payments', + requireExpenseAuthority('expenses.pay'), + (req: Request, res: Response) => { + try { + const body = req.body || {}; + requireCashDrawerAuthority(req, normalizeExpensePaymentMethod(body.method) === 'cash'); + const outcome = recordExpensePayment(getDatabase(), String(req.params.id), { + ...body, + actorUserId: actorId(req as AuthedRequest), + idempotencyKey: requireIdempotencyKey(req), + }); + if (outcome.replayed) res.set('Idempotent-Replay', 'true'); + res.status(outcome.status).json(outcome.body); + } catch (error: unknown) { + sendError(res, error); + } + }, +); /** Reversals copy the committed payment; a correction is never an in-place edit. */ -router.post('/:id/payments/:paymentId/reverse', requireExpenseAuthority('expenses.reverse'), (req: Request, res: Response) => { - try { - const db = getDatabase(); - const expenseId = String(req.params.id); - const paymentId = String(req.params.paymentId); - requireCashDrawerAuthority(req, getExpensePayment(db, expenseId, paymentId).method === 'cash'); - const outcome = reverseExpensePayment(db, expenseId, paymentId, { - reason: (req.body || {}).reason, - actorUserId: actorId(req as AuthedRequest), - idempotencyKey: requireIdempotencyKey(req), - }); - if (outcome.replayed) res.set('Idempotent-Replay', 'true'); - res.status(outcome.status).json(outcome.body); - } catch (error: unknown) { - sendError(res, error); - } -}); +router.post( + '/:id/payments/:paymentId/reverse', + requireExpenseAuthority('expenses.reverse'), + (req: Request, res: Response) => { + try { + const db = getDatabase(); + const expenseId = String(req.params.id); + const paymentId = String(req.params.paymentId); + requireCashDrawerAuthority( + req, + getExpensePayment(db, expenseId, paymentId).method === 'cash', + ); + const outcome = reverseExpensePayment(db, expenseId, paymentId, { + reason: (req.body || {}).reason, + actorUserId: actorId(req as AuthedRequest), + idempotencyKey: requireIdempotencyKey(req), + }); + if (outcome.replayed) res.set('Idempotent-Replay', 'true'); + res.status(outcome.status).json(outcome.body); + } catch (error: unknown) { + sendError(res, error); + } + }, +); export const expenseRoutes = router; diff --git a/main/routes/printers.ts b/main/routes/printers.ts index fd2275d7f..1b0bb4fae 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -49,7 +49,7 @@ import { capabilitiesForPrinter, } from '../printers/profiles'; import { hasPermission, requirePermission, requireAnyPermission } from '../services/authorization'; -import { getOpenSession } from '../services/shift-session-gate'; +import { getOpenSession, listOpenSessions } from '../services/shift-session-gate'; import { formatCurrencyForTenant, formatDateForTenant, @@ -550,7 +550,12 @@ router.post( asyncHandler(async (req: Request, res: Response) => { try { const db = getDatabase(); - if (!getOpenSession(db)) { + const actorUserId = String((req as any).user?.userId || ''); + const actorRole = String((req as any).user?.role || ''); + const hasOpenShift = + Boolean(getOpenSession(db, actorUserId)) || + ((actorRole === 'owner' || actorRole === 'manager') && listOpenSessions(db).length > 0); + if (!hasOpenShift) { return res.status(409).json({ error: 'open_cash_shift_required' }); } const printer = getDefaultPrinter(db); diff --git a/main/routes/refunds.ts b/main/routes/refunds.ts index 64ec64121..a4fd9cdda 100644 --- a/main/routes/refunds.ts +++ b/main/routes/refunds.ts @@ -281,6 +281,7 @@ router.post('/', requirePermission('refunds.initiate'), async (req: Request, res method: body.method, amountCents, orderItemId, + createdByUserId: userId, }); const overridePin = body.override_pin; diff --git a/main/routes/reports.ts b/main/routes/reports.ts index 31aa3867b..1a05e4382 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -7,6 +7,7 @@ import { getDatabase, getSettingValue, localDateInTimezone, + now, parseDbTimestamp, tenantBusinessDayStartTime, } from '../db'; @@ -23,11 +24,20 @@ import { import { aggregateTaxComponents } from '../services/tax-components'; import { getTenantCurrency } from '../services/refund'; import { getCurrencyMinorUnitFactor, resolveRegionalSnapshot } from '../countries'; -import { openSessionBlockingDayClose } from '../services/cash-day-close-guards'; +import { + openSessionBlockingDayClose, + openSessionsBlockingDayClose, + sessionBusinessDate, +} from '../services/cash-day-close-guards'; +import type { CashSessionRow } from '../services/shift-session-gate'; import { computeDayAggregates, + computePeriodAggregates, expectedCashFromAggregates, + listCashDrawerMovementsForSession, paymentMethodBreakdown, + sessionExpectedCash, + sessionFinancialTotals, } from './cash-closures'; import { getCurrencyFractionDigits } from '../countries'; import { roundCurrencyCost } from '../services/supplies'; @@ -1163,7 +1173,100 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R ) .get(date) as { business_date: string; counted_cash_cents: number } | undefined; - const blockingSession = openSessionBlockingDayClose(db, date); + const blockingSessions = openSessionsBlockingDayClose(db, date); + const blockingSession = blockingSessions[0]; + const openShifts = blockingSessions.map((s) => ({ + id: s.id, + opened_by: s.opened_by, + opened_by_name: s.opened_by_name, + opened_at: s.opened_at, + })); + + if (req.query.session_id !== undefined && String(req.query.session_id).trim() !== '') { + const sessionId = Number(req.query.session_id); + if (!Number.isInteger(sessionId) || sessionId <= 0) { + return res.status(400).json({ error: 'Invalid session_id', code: 'session_id_invalid' }); + } + const session = db.prepare(`SELECT * FROM cash_sessions WHERE id = ?`).get(sessionId) as + | CashSessionRow + | undefined; + if (!session) { + return res.status(404).json({ error: 'Shift not found', code: 'session_unavailable' }); + } + const sessionEnd = session.closed_at || now(); + const sessionMovements = listCashDrawerMovementsForSession( + db, + session.id, + session.opened_at, + sessionEnd, + false, + ); + const openingFloatCents = Number(session.opening_float_cents || 0); + const sessionAggregates = { + ...computePeriodAggregates( + db, + session.opened_at, + sessionEnd, + sessionMovements, + openingFloatCents, + ), + ...sessionFinancialTotals(db, session, sessionEnd), + }; + const totalExpectedCashCents = sessionExpectedCash(db, session, sessionEnd, sessionMovements); + return res.json({ + xReport: { + businessDate: sessionBusinessDate(db, session), + periodStart: session.opened_at, + periodEnd: sessionEnd, + grossCollected: sessionAggregates.grossCollectedCents / minorFactor, + refunded: sessionAggregates.refundedCents / minorFactor, + netCollected: sessionAggregates.netCollectedCents / minorFactor, + billCount: sessionAggregates.billCount, + refundCount: sessionAggregates.refundCount, + paymentMethods: sessionAggregates.paymentMethods.map((row) => ({ + method: row.method, + count: row.count, + total: row.total_cents / minorFactor, + })), + staffSales: sessionAggregates.staffSales.map((row) => ({ + user_id: row.user_id, + name: row.name, + role: row.role, + revenue: row.revenue_cents / minorFactor, + orderCount: row.orderCount, + })), + taxComponents: sessionAggregates.taxComponents, + openingFloatCents, + payInCents: sessionAggregates.payInCents, + payOutCents: sessionAggregates.payOutCents, + safeDropCents: sessionAggregates.safeDropCents, + cashMovements: sessionAggregates.cashMovements, + expectedCashCents: totalExpectedCashCents - openingFloatCents, + priorClosedCashCents: priorRow?.counted_cash_cents ?? null, + priorBusinessDate: priorRow?.business_date ?? null, + alreadyClosed: !!closedRow, + zNumber: closedRow?.z_number, + session: { + id: session.id, + opened_by: session.opened_by, + opened_by_name: session.opened_by_name, + opened_at: session.opened_at, + closed_at: session.closed_at, + status: session.status, + opening_float_cents: openingFloatCents, + expected_cash_cents: totalExpectedCashCents, + }, + openShifts, + blockingOpenShift: blockingSession + ? { + id: blockingSession.id, + opened_by_name: blockingSession.opened_by_name, + opened_at: blockingSession.opened_at, + } + : null, + }, + }); + } res.json({ xReport: { @@ -1208,6 +1311,8 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R priorBusinessDate: priorRow?.business_date ?? null, alreadyClosed: !!closedRow, zNumber: closedRow?.z_number, + session: null, + openShifts, blockingOpenShift: blockingSession ? { id: blockingSession.id, diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 3a38b314d..eeb86bc7b 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -173,6 +173,8 @@ const OPTIONAL_SETTING_DEFAULTS: Record = { printer_trim_decimals: 'false', split_checks_enabled: 'false', card_transaction_id_required: 'false', + require_open_shift: 'false', + one_shift_per_staff: 'false', // Print language policies (#441) — inherit store language, no second // language. Defaults preserve pre-policy behavior for existing tenants. [BILL_LANGUAGE_POLICY_KEY]: defaultLanguagePolicySettingJson(), @@ -1901,6 +1903,7 @@ const ALLOWED_WILDCARD_KEYS = new Set([ 'pos_category_sort', 'pos_item_sort', 'require_open_shift', + 'one_shift_per_staff', 'auto_lock_minutes', ]); @@ -2187,6 +2190,7 @@ router.put( } if ( req.params.key === 'require_open_shift' || + req.params.key === 'one_shift_per_staff' || req.params.key === 'card_transaction_id_required' ) { if (value !== true && value !== false && value !== 'true' && value !== 'false') { @@ -2215,6 +2219,17 @@ router.put( valueToPersist = validation.stored; } const db = getDatabase(); + if (req.params.key === 'one_shift_per_staff' && valueToPersist === 'false') { + const openCount = db + .prepare(`SELECT COUNT(*) AS n FROM cash_sessions WHERE status = 'open'`) + .get() as { n: number } | undefined; + if (Number(openCount?.n || 0) > 1) { + return res.status(409).json({ + error: 'Cannot disable one shift per staff member while multiple shifts are open', + code: 'multiple_shifts_open', + }); + } + } if ( diagnosticsChoice && valueToPersist === 'true' && diff --git a/main/services/cash-day-close-guards.ts b/main/services/cash-day-close-guards.ts index 3168b0f09..22150bed1 100644 --- a/main/services/cash-day-close-guards.ts +++ b/main/services/cash-day-close-guards.ts @@ -11,7 +11,7 @@ import { } from '../db'; import { getCurrencyMinorUnitFactor, resolveRegionalSnapshot } from '../countries'; import { getTenantCurrency } from './refund'; -import { getOpenSession, type CashSessionRow } from './shift-session-gate'; +import { listOpenSessions, type CashSessionRow } from './shift-session-gate'; function tenantTimezone(): string { return resolveRegionalSnapshot({ @@ -33,15 +33,20 @@ export function sessionBusinessDate( ); } +/** All open shifts that must close before day-close for `businessDate`. */ +export function openSessionsBlockingDayClose( + db: ReturnType, + businessDate: string, +): CashSessionRow[] { + return listOpenSessions(db).filter((session) => sessionBusinessDate(db, session) <= businessDate); +} + /** Open shift that must close before day-close for `businessDate`, if any. */ export function openSessionBlockingDayClose( db: ReturnType, businessDate: string, ): CashSessionRow | undefined { - const session = getOpenSession(db); - if (!session) return undefined; - if (sessionBusinessDate(db, session) > businessDate) return undefined; - return session; + return openSessionsBlockingDayClose(db, businessDate)[0]; } export interface ShiftUnpaidOrderSample { diff --git a/main/services/expenses.ts b/main/services/expenses.ts index 9a340502b..c2ee66391 100644 --- a/main/services/expenses.ts +++ b/main/services/expenses.ts @@ -1193,8 +1193,8 @@ function requireExpectedCurrency(value: unknown, expected: string): void { } /** Cash entries move real drawer money, so they need a real open session. */ -function requireOpenSessionForCashEntry(db: ExpenseDb): CashSessionRow { - const session = getOpenSession(db); +function requireOpenSessionForCashEntry(db: ExpenseDb, actorUserId?: string): CashSessionRow { + const session = getOpenSession(db, actorUserId); if (!session) { throw conflict( 'No open cash session: open a shift before moving drawer money for an expense', @@ -1279,7 +1279,7 @@ export function recordExpensePayment( const businessDate = currentBusinessDate(); let cashMovementId: number | null = null; if (method === 'cash') { - const session = requireOpenSessionForCashEntry(db); + const session = requireOpenSessionForCashEntry(db, input.actorUserId); cashMovementId = insertCashDrawerMovement(db, { businessDate, movementType: 'pay_out', @@ -1353,7 +1353,7 @@ export function reverseExpensePayment( const businessDate = currentBusinessDate(); let cashMovementId: number | null = null; if (payment.method === 'cash') { - const session = requireOpenSessionForCashEntry(db); + const session = requireOpenSessionForCashEntry(db, input.actorUserId); cashMovementId = insertCashDrawerMovement(db, { businessDate, movementType: 'pay_in', @@ -1401,10 +1401,10 @@ export interface ExpenseContext { } /** Entry defaults for the expense surfaces: store currency, store business date, drawer state. */ -export function getExpenseContext(db: ExpenseDb): ExpenseContext { +export function getExpenseContext(db: ExpenseDb, actorUserId?: string): ExpenseContext { return { currency_code: tenantRegionalSnapshot().currency, business_date: currentBusinessDate(), - cash_session_open: getOpenSession(db) !== undefined, + cash_session_open: getOpenSession(db, actorUserId) !== undefined, }; } diff --git a/main/services/refund.ts b/main/services/refund.ts index f35e6a292..d5329b644 100644 --- a/main/services/refund.ts +++ b/main/services/refund.ts @@ -205,10 +205,11 @@ export function validateRefundPreconditions( method?: string; amountCents?: number; orderItemId?: number | null; + createdByUserId?: string; }, nowMs: number = Date.now(), ): ValidatedRefundPreconditions { - requireOpenSessionForCashTender(db, [{ method: req.method }]); + requireOpenSessionForCashTender(db, [{ method: req.method }], req.createdByUserId); const { lateRefund, bill } = checkIsLateRefund(db, req.billId, nowMs); @@ -476,7 +477,7 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { } } - const cashSessionId = getOpenSession(db)?.id ?? NO_CASH_SESSION_ID; + const cashSessionId = getOpenSession(db, req.createdByUserId)?.id ?? NO_CASH_SESSION_ID; const insertResult = db .prepare(` INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at, cash_session_id) diff --git a/main/services/shift-session-gate.ts b/main/services/shift-session-gate.ts index 22aaab3f3..410de20aa 100644 --- a/main/services/shift-session-gate.ts +++ b/main/services/shift-session-gate.ts @@ -29,10 +29,40 @@ export interface CashSessionRow { // through the legacy timestamp fallback. export const NO_CASH_SESSION_ID = 0; -export function getOpenSession(db: ReturnType): CashSessionRow | undefined { - return db.prepare(`SELECT * FROM cash_sessions WHERE status = 'open' LIMIT 1`).get() as - | CashSessionRow - | undefined; +export function isPerStaffShiftsEnabled(db?: ReturnType): boolean { + if (db) { + const row = db.prepare(`SELECT value FROM settings WHERE key = 'one_shift_per_staff'`).get() as + | { value?: string } + | undefined; + return row?.value === 'true'; + } + return getSettingValue('one_shift_per_staff') === 'true'; +} + +export function listOpenSessions(db: ReturnType): CashSessionRow[] { + return db + .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' ORDER BY opened_at ASC, id ASC`) + .all() as CashSessionRow[]; +} + +export function getOpenSession( + db: ReturnType, + userId?: string | null, +): CashSessionRow | undefined { + if (!isPerStaffShiftsEnabled(db)) { + return db + .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' ORDER BY id ASC LIMIT 1`) + .get() as CashSessionRow | undefined; + } + const normalizedUserId = typeof userId === 'string' ? userId.trim() : ''; + if (normalizedUserId) { + return db + .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' AND opened_by = ? LIMIT 1`) + .get(normalizedUserId) as CashSessionRow | undefined; + } + return db + .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' ORDER BY id ASC LIMIT 1`) + .get() as CashSessionRow | undefined; } const BUILT_IN_PAYMENT_METHODS = new Set(['cash', 'card', 'wallet']); @@ -67,9 +97,12 @@ export function isCashTender(db: ReturnType, payment: unknow * needs an open session. Non-cash tenders are never gated. Missing setting * rows (pre-v90 upgrades) read as off. */ -export function requireOpenSessionForCash(db: ReturnType): void { +export function requireOpenSessionForCash( + db: ReturnType, + userId?: string | null, +): void { if (getSettingValue('require_open_shift') !== 'true') return; - if (!getOpenSession(db)) { + if (!getOpenSession(db, userId)) { throw httpError('No open shift: open a shift before taking cash', 409, 'cash_session_required'); } } @@ -81,9 +114,10 @@ export function requireOpenSessionForCash(db: ReturnType): v export function requireOpenSessionForCashTender( db: ReturnType, payments: unknown[], + userId?: string | null, ): void { if (getSettingValue('require_open_shift') !== 'true') return; - if (payments.some((line) => isCashTender(db, line)) && !getOpenSession(db)) { + if (payments.some((line) => isCashTender(db, line)) && !getOpenSession(db, userId)) { throw httpError('No open shift: open a shift before taking cash', 409, 'cash_session_required'); } } diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index 927191d88..6305713c4 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -129,14 +129,21 @@ async function main() { assert(cols.includes('opening_float_cents'), 'cash_sessions has opening_float_cents'); assert(cols.includes('status'), 'cash_sessions has status'); assert(cols.includes('closure_id'), 'cash_sessions has closure_id'); - const oneOpen = db + const legacyOneOpen = db .prepare(`SELECT sql FROM sqlite_master WHERE name = 'cash_sessions_one_open'`) .get() as { sql: string } | undefined; + assert(!legacyOneOpen, 'legacy global cash_sessions_one_open index is dropped by migration 120'); + const oneOpenPerUser = db + .prepare(`SELECT sql FROM sqlite_master WHERE name = 'cash_sessions_one_open_per_user'`) + .get() as { sql: string } | undefined; assert( - !!oneOpen && oneOpen.sql.includes('WHERE'), - 'partial unique index allows a single open session', + !!oneOpenPerUser && + oneOpenPerUser.sql.includes('opened_by') && + oneOpenPerUser.sql.includes('WHERE'), + 'partial unique index allows one open session per user', ); assert(getSettingValue('require_open_shift') === 'false', 'enforcement defaults to off'); + assert(getSettingValue('one_shift_per_staff') === 'false', 'per-staff shifts default to off'); assert(getSettingValue('stale_session_days') === '7', 'stale threshold defaults to 7 days'); const movementOwnershipCols = ( db.prepare(`PRAGMA table_info(cash_drawer_movements)`).all() as { name: string }[] @@ -2266,6 +2273,594 @@ async function main() { setTenantTimeConfig('UTC', '00:00'); db.prepare(`DELETE FROM printers WHERE id = 'webusb-1360'`).run(); } + + // ── Section 11: opt-in one cash shift per staff member (#1339) ────────── + console.log('Section 11: opt-in one cash shift per staff member (#1339)'); + const { expenseRoutes } = require('../main/routes/expenses'); + const { reportRoutes } = require('../main/routes/reports'); + app.use('/api/expenses', expenseRoutes); + app.use('/api/reports', reportRoutes); + const s11CashierAToken = jwt.sign( + { userId: 'cashier-sess', email: 'cashier-sess@test.local', role: 'cashier' }, + getJWTSecret(), + { expiresIn: '365d' }, + ); + const s11CashierBToken = jwt.sign( + { userId: 'cashier2-sess', email: 'cashier2-sess@test.local', role: 'cashier' }, + getJWTSecret(), + { expiresIn: '365d' }, + ); + const s11ManagerToken = jwt.sign( + { userId: 'manager-sess', email: 'manager-sess@test.local', role: 'manager' }, + getJWTSecret(), + { expiresIn: '365d' }, + ); + const s11OwnerToken = jwt.sign( + { userId: 'owner-sess', email: 'owner-sess@test.local', role: 'owner' }, + getJWTSecret(), + { expiresIn: '365d' }, + ); + + const seedBillOnDate = (tag: string, isoMoment: string, amountMajor = 50): number => { + const tsMoment = isoMoment.replace('T', ' ').replace(/\..*$/, ''); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES (?, 'cashier-sess', 'takeaway', 'pending', ?, ?, ?, ?, NULL)`, + ).run(`ORD-1339-${tag}`, amountMajor, amountMajor, tsMoment, tsMoment); + const oid = Number( + (db.prepare(`SELECT id FROM orders WHERE order_number = ?`).get(`ORD-1339-${tag}`) as any) + .id, + ); + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, ?, 'unpaid', '[]', NULL, ?, ?)`, + ).run(`BILL-1339-${tag}`, oid, amountMajor, amountMajor, amountMajor, tsMoment, tsMoment); + return Number( + (db.prepare(`SELECT id FROM bills WHERE bill_number = ?`).get(`BILL-1339-${tag}`) as any) + .id, + ); + }; + + try { + // 1) Setting off by default: second user opening a shift is refused with 409 session_already_open + assert( + getSettingValue('one_shift_per_staff') === 'false', + '#1339: one_shift_per_staff is off before per-staff test', + ); + await withFixedClock('2026-11-10T09:00:00.000Z', async () => { + const s1 = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 1000 }); + assert(s1.status === 200, '#1339 (setting off): User A opens shift (200)'); + + const s2Blocked = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 1000 }); + assert( + s2Blocked.status === 409 && s2Blocked.body?.display_code === 'session_already_open', + '#1339 (setting off): User B opening second shift is refused with 409 session_already_open', + ); + + const s1Close = await request(app) + .post(`/api/cash-sessions/${s1.body?.id}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 1000 }); + assert(s1Close.status === 200, '#1339 (setting off): User A closes shift (200)'); + }); + db.prepare( + `DELETE FROM cash_drawer_movements WHERE business_date = '2026-11-10' AND movement_type = 'opening_float'`, + ).run(); + db.prepare(`DELETE FROM cash_sessions WHERE opened_at LIKE '2026-11-10%'`).run(); + db.prepare(`DELETE FROM cash_closures WHERE business_date = '2026-11-10'`).run(); + + // 2) Settings validation & toggle permissions + const unauthPerStaff = await request(app) + .put('/api/settings/one_shift_per_staff') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ value: true }); + assert( + unauthPerStaff.status === 403 && getSettingValue('one_shift_per_staff') === 'false', + '#1339: cashier cannot toggle one_shift_per_staff (403)', + ); + + const invalidPerStaff = await request(app) + .put('/api/settings/one_shift_per_staff') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ value: 'invalid' }); + assert( + invalidPerStaff.status === 400 && getSettingValue('one_shift_per_staff') === 'false', + '#1339: invalid non-boolean value for one_shift_per_staff returns 400', + ); + + const enablePerStaff = await request(app) + .put('/api/settings/one_shift_per_staff') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ value: true }); + assert( + enablePerStaff.status === 200 && getSettingValue('one_shift_per_staff') === 'true', + '#1339: owner enables one_shift_per_staff (200)', + ); + + // 3) Concurrent shifts for User A and User B on 2026-11-10 + let shiftAId = 0; + let shiftBId = 0; + await withFixedClock('2026-11-10T10:00:00.000Z', async () => { + const openA = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 5000 }); + assert( + openA.status === 200, + '#1339 (setting on): User A opens Shift A with 5000 float (200)', + ); + shiftAId = Number(openA.body?.id); + + const dupOpenA = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 2000 }); + assert( + dupOpenA.status === 409 && dupOpenA.body?.display_code === 'session_already_open', + '#1339 (setting on): User A cannot open a second concurrent shift for themselves (409)', + ); + + const openB = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 3000 }); + assert( + openB.status === 200 && Number(openB.body?.id) !== shiftAId, + '#1339 (setting on): User B concurrently opens Shift B with 3000 float (200)', + ); + shiftBId = Number(openB.body?.id); + }); + + const dayFloatRow = db + .prepare( + `SELECT amount_cents FROM cash_drawer_movements + WHERE business_date = '2026-11-10' AND movement_type = 'opening_float' AND voided_at IS NULL`, + ) + .get() as { amount_cents: number } | undefined; + assert( + dayFloatRow?.amount_cents === 8000, + `#1339: day opening_float aggregates across concurrent shifts (5000 + 3000 = 8000, got ${dayFloatRow?.amount_cents})`, + ); + + // 4) Refusing to turn one_shift_per_staff off while >1 shift is open + const disableWhileMultipleOpen = await request(app) + .put('/api/settings/one_shift_per_staff') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ value: false }); + assert( + disableWhileMultipleOpen.status === 409 && + disableWhileMultipleOpen.body?.code === 'multiple_shifts_open' && + getSettingValue('one_shift_per_staff') === 'true', + '#1339: disabling one_shift_per_staff while 2 shifts are open is refused with 409 multiple_shifts_open', + ); + + // 5) Actor-scoped money routing: payments, drawer movements, refunds, and cash expenses + // Grant cashier1 refund initiation and cashier2 expense permissions so each staff member can exercise their own shift. + db.prepare( + `INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, updated_at) + VALUES ('cashier-sess', 'refunds.initiate', 'allow', 'owner-sess', ?) + ON CONFLICT(user_id, permission_id) DO UPDATE SET effect = 'allow', updated_at = excluded.updated_at`, + ).run(now()); + for (const permId of ['expenses.view', 'expenses.manage', 'expenses.pay']) { + db.prepare( + `INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, updated_at) + VALUES ('cashier2-sess', ?, 'allow', 'owner-sess', ?) + ON CONFLICT(user_id, permission_id) DO UPDATE SET effect = 'allow', updated_at = excluded.updated_at`, + ).run(permId, now()); + } + + await withFixedClock('2026-11-10T11:00:00.000Z', async () => { + // User A takes 40.00 (4000c) cash payment + const billAId = seedBillOnDate('A', '2026-11-10T11:00:00.000Z', 40); + const payA = await request(app) + .post(`/api/bills/${billAId}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 40 }); + assert(payA.status === 200, '#1339: User A records 40.00 cash payment (200)'); + const detailsA = JSON.parse( + (db.prepare('SELECT payment_details FROM bills WHERE id = ?').get(billAId) as any) + .payment_details, + ); + assert( + Number(detailsA[0]?.cash_session_id) === shiftAId, + '#1339: User A cash payment attaches to Shift A', + ); + + // User B takes 60.00 (6000c) cash payment + const billBId = seedBillOnDate('B', '2026-11-10T11:00:00.000Z', 60); + const payB = await request(app) + .post(`/api/bills/${billBId}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 60 }); + assert(payB.status === 200, '#1339: User B records 60.00 cash payment (200)'); + const detailsB = JSON.parse( + (db.prepare('SELECT payment_details FROM bills WHERE id = ?').get(billBId) as any) + .payment_details, + ); + assert( + Number(detailsB[0]?.cash_session_id) === shiftBId, + '#1339: User B cash payment attaches to Shift B', + ); + + // User A records 1000c pay_in movement + const movA = await request(app) + .post('/api/cash-closures/movements') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ + business_date: '2026-11-10', + movement_type: 'pay_in', + amount_cents: 1000, + reason: 'User A change', + }); + assert( + movA.status === 201 && Number(movA.body?.movement?.cash_session_id) === shiftAId, + '#1339: User A drawer pay_in movement attaches to Shift A (201)', + ); + + // User B records 500c pay_out movement + const movB = await request(app) + .post('/api/cash-closures/movements') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ + business_date: '2026-11-10', + movement_type: 'pay_out', + amount_cents: 500, + reason: 'User B supplies', + }); + assert( + movB.status === 201 && Number(movB.body?.movement?.cash_session_id) === shiftBId, + '#1339: User B drawer pay_out movement attaches to Shift B (201)', + ); + + // User A records 10.00 (1000c) cash refund on Bill A + const refundA = await request(app) + .post('/api/refunds') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ + bill_id: billAId, + amount: 10, + method: 'cash', + approver_id: 'owner-sess', + override_pin: '1234', + }); + assert(refundA.status === 201, '#1339: User A records 10.00 cash refund (201)'); + const refundARow = db + .prepare('SELECT cash_session_id FROM refunds WHERE id = ?') + .get(refundA.body?.refund?.id) as { cash_session_id: number } | undefined; + assert( + Number(refundARow?.cash_session_id) === shiftAId, + '#1339: User A cash refund attaches to Shift A', + ); + + // User B creates and pays a 1500c cash expense + const catRes = await request(app) + .post('/api/expenses/categories') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .set('Idempotency-Key', '1339-cat-1') + .send({ name: 'Shift B Supplies' }); + assert(catRes.status === 201, '#1339: expense category created (201)'); + const catId = catRes.body?.category?.id; + + const expRes = await request(app) + .post('/api/expenses') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .set('Idempotency-Key', '1339-exp-1') + .send({ + category_id: catId, + description: 'Milk run', + amount_minor: 1500, + incurred_on: '2026-11-10', + }); + assert(expRes.status === 201, '#1339: expense created (201)'); + const expId = expRes.body?.expense?.id; + + // Manager has no open shift -> cash expense payment by manager is refused with 409 cash_session_required + const managerExpCtx = await request(app) + .get('/api/expenses/context') + .set('Authorization', `Bearer ${s11ManagerToken}`); + assert( + managerExpCtx.status === 200 && managerExpCtx.body?.cash_session_open === false, + '#1339: manager without own open shift sees cash_session_open=false in expense context', + ); + const managerExpPayBlocked = await request(app) + .post(`/api/expenses/${expId}/payments`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .set('Idempotency-Key', '1339-exp-pay-mgr') + .send({ method: 'cash', amount_minor: 1500 }); + assert( + managerExpPayBlocked.status === 409 && + managerExpPayBlocked.body?.code === 'cash_session_required', + '#1339: manager without own open shift cannot pay cash expense even when staff shifts are open (409)', + ); + + // User B pays the 1500c cash expense -> routes to Shift B + const expPayB = await request(app) + .post(`/api/expenses/${expId}/payments`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .set('Idempotency-Key', '1339-exp-pay-b') + .send({ method: 'cash', amount_minor: 1500 }); + assert(expPayB.status === 201, '#1339: User B records 1500c cash expense payment (201)'); + const expMovId = expPayB.body?.payment?.cash_movement_id; + const expMovRow = db + .prepare( + 'SELECT cash_session_id, amount_cents, movement_type FROM cash_drawer_movements WHERE id = ?', + ) + .get(expMovId) as + | { cash_session_id: number; amount_cents: number; movement_type: string } + | undefined; + assert( + Number(expMovRow?.cash_session_id) === shiftBId && + expMovRow?.amount_cents === 1500 && + expMovRow?.movement_type === 'pay_out', + '#1339: User B cash expense payment writes a pay_out movement on Shift B', + ); + + // Manager without an open shift taking a cash bill payment: + // When require_open_shift is off -> NO_CASH_SESSION_ID (0) + // When require_open_shift is on -> 409 cash_session_required + db.prepare(`UPDATE settings SET value = 'true' WHERE key = 'require_open_shift'`).run(); + const billMgrId = seedBillOnDate('MGR', '2026-11-10T11:00:00.000Z', 25); + const payMgrBlocked = await request(app) + .post(`/api/bills/${billMgrId}/payment`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ method: 'cash', amount: 25 }); + assert( + payMgrBlocked.status === 409, + '#1339: with require_open_shift on, manager without own open shift is blocked from cash payment (409)', + ); + db.prepare(`UPDATE settings SET value = 'false' WHERE key = 'require_open_shift'`).run(); + const payMgrUnscoped = await request(app) + .post(`/api/bills/${billMgrId}/payment`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ method: 'cash', amount: 25 }); + assert( + payMgrUnscoped.status === 200, + '#1339: with require_open_shift off, manager without own open shift records cash payment with NO_CASH_SESSION_ID (200)', + ); + const detailsMgr = JSON.parse( + (db.prepare('SELECT payment_details FROM bills WHERE id = ?').get(billMgrId) as any) + .payment_details, + ); + assert( + Number(detailsMgr[0]?.cash_session_id) === 0, + '#1339: sessionless actor payment stores cash_session_id = 0 (NO_CASH_SESSION_ID) and does not leak into Shift A or B', + ); + // Delete the unscoped manager test bill so day close compares purely Shift A + Shift B + db.prepare('DELETE FROM bills WHERE id = ?').run(billMgrId); + db.prepare(`DELETE FROM orders WHERE order_number = 'ORD-1339-MGR'`).run(); + }); + + // Expected cash for Shift A: 5000 float + 4000 sale + 1000 pay_in - 1000 refund = 9000 + // Expected cash for Shift B: 3000 float + 6000 sale - 500 pay_out - 1500 expense pay_out = 7000 + await withFixedClock('2026-11-10T12:00:00.000Z', async () => { + const curA = await request(app) + .get('/api/cash-sessions/current') + .set('Authorization', `Bearer ${s11CashierAToken}`); + assert( + curA.status === 200 && + Number(curA.body?.id) === shiftAId && + curA.body?.expected_cash_cents === 9000, + `#1339: User A GET /current returns Shift A with expected_cash_cents=9000 (got ${curA.body?.expected_cash_cents})`, + ); + + const curB = await request(app) + .get('/api/cash-sessions/current') + .set('Authorization', `Bearer ${s11CashierBToken}`); + assert( + curB.status === 200 && + Number(curB.body?.id) === shiftBId && + curB.body?.expected_cash_cents === 7000, + `#1339: User B GET /current returns Shift B with expected_cash_cents=7000 (got ${curB.body?.expected_cash_cents})`, + ); + + // Manager listing all open shifts via GET /api/cash-sessions/open + const openListMgr = await request(app) + .get('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11ManagerToken}`); + assert( + openListMgr.status === 200 && + Array.isArray(openListMgr.body?.sessions) && + openListMgr.body.sessions.length === 2, + '#1339: manager GET /api/cash-sessions/open returns both open shifts', + ); + // Cashier listing via GET /api/cash-sessions/open sees only their own shift + const openListCashierA = await request(app) + .get('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`); + assert( + openListCashierA.status === 200 && + Array.isArray(openListCashierA.body?.sessions) && + openListCashierA.body.sessions.length === 1 && + Number(openListCashierA.body.sessions[0]?.id) === shiftAId, + '#1339: cashier GET /api/cash-sessions/open returns only their own open shift', + ); + + // 6) X Report: all open shifts vs single shift (?session_id=) + const xAll = await request(app) + .get('/api/reports/x-report?date=2026-11-10') + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xAllRep = xAll.body?.xReport; + assert( + xAll.status === 200 && + Array.isArray(xAllRep?.openShifts) && + xAllRep.openShifts.length === 2 && + xAllRep?.openingFloatCents === 8000 && + xAllRep?.expectedCashCents === 8000 && + xAllRep?.grossCollected === 100 && + xAllRep?.refunded === 10 && + xAllRep?.netCollected === 90, + `#1339: unscoped X report covers all shifts (expectedCashCents=8000 net of float, openingFloatCents=8000, got ${xAllRep?.expectedCashCents})`, + ); + + const xShiftA = await request(app) + .get(`/api/reports/x-report?date=2026-11-10&session_id=${shiftAId}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xShiftARep = xShiftA.body?.xReport; + assert( + xShiftA.status === 200 && + Number(xShiftARep?.session?.id) === shiftAId && + xShiftARep?.openingFloatCents === 5000 && + xShiftARep?.expectedCashCents === 4000 && + xShiftARep?.session?.expected_cash_cents === 9000 && + xShiftARep?.grossCollected === 40 && + xShiftARep?.refunded === 10 && + xShiftARep?.netCollected === 30, + `#1339: X report with ?session_id=${shiftAId} scopes to Shift A (expected_cash_cents=9000, got ${xShiftARep?.session?.expected_cash_cents})`, + ); + + const xShiftB = await request(app) + .get(`/api/reports/x-report?date=2026-11-10&session_id=${shiftBId}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xShiftBRep = xShiftB.body?.xReport; + assert( + xShiftB.status === 200 && + Number(xShiftBRep?.session?.id) === shiftBId && + xShiftBRep?.openingFloatCents === 3000 && + xShiftBRep?.expectedCashCents === 4000 && + xShiftBRep?.session?.expected_cash_cents === 7000 && + xShiftBRep?.grossCollected === 60 && + xShiftBRep?.refunded === 0 && + xShiftBRep?.netCollected === 60 && + xShiftBRep?.payOutCents === 2000, + `#1339: X report with ?session_id=${shiftBId} scopes to Shift B (expected_cash_cents=7000, payOut=2000, got ${xShiftBRep?.session?.expected_cash_cents})`, + ); + + // 7) Close authority & Day Close waiting until ALL shifts are closed + // Cashier A cannot preview or close Cashier B's shift + const crossPreview = await request(app) + .get(`/api/cash-sessions/current/close-preview?session_id=${shiftBId}`) + .set('Authorization', `Bearer ${s11CashierAToken}`); + assert( + crossPreview.status === 403 && + crossPreview.body?.display_code === 'session_owner_required', + '#1339: Cashier A cannot close-preview Cashier B shift (403 session_owner_required)', + ); + const crossClose = await request(app) + .post(`/api/cash-sessions/${shiftBId}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 7000 }); + assert( + crossClose.status === 403 && crossClose.body?.display_code === 'session_owner_required', + '#1339: Cashier A cannot close Cashier B shift (403 session_owner_required)', + ); + + // Day close is blocked while both shifts are open + const dayBlockedBoth = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-10', + opening_float_cents: 8000, + counted_cash_cents: 16000, + }); + assert( + dayBlockedBoth.status === 409 && + dayBlockedBoth.body?.display_code === 'open_shift_blocks_day_close' && + Array.isArray(dayBlockedBoth.body?.blocking_shifts) && + dayBlockedBoth.body.blocking_shifts.length === 2, + '#1339: day close is blocked (409) while both shifts are open and lists 2 blocking_shifts', + ); + + // Cashier A closes their own shift (Shift A) + const closeA = await request(app) + .post(`/api/cash-sessions/${shiftAId}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 9000 }); + assert( + closeA.status === 200 && + closeA.body?.expected_cash_cents === 9000 && + closeA.body?.variance_cents === 0, + '#1339: Cashier A closes Shift A with 9000c (200)', + ); + + // Day close is STILL blocked while Shift B remains open + const dayBlockedOneLeft = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-10', + opening_float_cents: 8000, + counted_cash_cents: 16000, + }); + assert( + dayBlockedOneLeft.status === 409 && + dayBlockedOneLeft.body?.display_code === 'open_shift_blocks_day_close' && + Number(dayBlockedOneLeft.body?.blocking_shift?.id) === shiftBId, + '#1339: day close remains blocked (409) while Shift B is still open', + ); + + // Manager can preview and close Cashier B's shift (Shift B) + const mgrPreviewB = await request(app) + .get(`/api/cash-sessions/current/close-preview?session_id=${shiftBId}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + assert( + mgrPreviewB.status === 200 && Number(mgrPreviewB.body?.session_id) === shiftBId, + '#1339: Manager can close-preview Cashier B shift (200)', + ); + const mgrCloseB = await request(app) + .post(`/api/cash-sessions/${shiftBId}/close`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ counted_cash_cents: 7000 }); + assert( + mgrCloseB.status === 200 && + mgrCloseB.body?.expected_cash_cents === 7000 && + mgrCloseB.body?.variance_cents === 0, + '#1339: Manager closes Cashier B shift with 7000c (200)', + ); + + // Once all shifts are closed, day close succeeds and sums Shift A + Shift B + const dayCloseSuccess = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-10', + opening_float_cents: 8000, + counted_cash_cents: 16000, + }); + assert( + dayCloseSuccess.status === 201, + `#1339: day close succeeds once both shifts are closed (201, got ${dayCloseSuccess.status})`, + ); + const dayZ = dayCloseSuccess.body?.zReport; + assert( + dayZ?.scope === 'day' && + dayZ?.opening_float_cents === 8000 && + dayZ?.expected_cash_cents === 16000 && + dayZ?.counted_cash_cents === 16000 && + dayZ?.variance_cents === 0 && + dayZ?.gross_collected_cents === 10000 && + dayZ?.refunded_cents === 1000 && + dayZ?.net_collected_cents === 9000 && + dayZ?.pay_in_cents === 1000 && + dayZ?.pay_out_cents === 2000 && + dayZ?.bill_count === 2 && + dayZ?.refund_count === 1, + `#1339: day Z report totals equal Shift A + Shift B (expected_cash=${dayZ?.expected_cash_cents}, net=${dayZ?.net_collected_cents})`, + ); + + // Turning one_shift_per_staff off now succeeds since <= 1 shift is open + const disableAfterClose = await request(app) + .put('/api/settings/one_shift_per_staff') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ value: false }); + assert( + disableAfterClose.status === 200 && getSettingValue('one_shift_per_staff') === 'false', + '#1339: owner disables one_shift_per_staff after concurrent shifts are closed (200)', + ); + }); + } finally { + db.prepare(`UPDATE settings SET value = 'false' WHERE key = 'one_shift_per_staff'`).run(); + db.prepare(`UPDATE settings SET value = 'false' WHERE key = 'require_open_shift'`).run(); + db.prepare( + `DELETE FROM user_permission_overrides WHERE user_id IN ('cashier-sess', 'cashier2-sess')`, + ).run(); + } } console.log('='.repeat(50)); diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts index 8e47544df..3399e2b3d 100644 --- a/tests/upgrade-path.test.ts +++ b/tests/upgrade-path.test.ts @@ -44,41 +44,61 @@ const FixtureDatabase = require('better-sqlite3'); const fixtureDb = new FixtureDatabase(upgradeDbPath); const legacyTaxBreakdown = JSON.stringify([{ title: 'Legacy Tax', rate: 5, amount: 5 }]); const legacyHeldItems = JSON.stringify([ - { id: 'line-legacy-1', product: { id: 'p-latte', name: 'Latte', price: 150 }, quantity: 2, addons: [], special_instructions: 'oat' }, + { + id: 'line-legacy-1', + product: { id: 'p-latte', name: 'Latte', price: 150 }, + quantity: 2, + addons: [], + special_instructions: 'oat', + }, ]); -const legacyOrder = fixtureDb.prepare(` +const legacyOrder = fixtureDb + .prepare(` INSERT INTO orders (order_number, subtotal, tax_amount, tax_breakdown, total) VALUES ('ORD-LEGACY-TAX', 100, 5, ?, 105) -`).run(legacyTaxBreakdown); -const legacyBill = fixtureDb.prepare(` +`) + .run(legacyTaxBreakdown); +const legacyBill = fixtureDb + .prepare(` INSERT INTO bills (bill_number, order_id, subtotal, tax_amount, tax_breakdown, total) VALUES ('INV-LEGACY-TAX', ?, 100, 5, ?, 105) -`).run(legacyOrder.lastInsertRowid, legacyTaxBreakdown); -fixtureDb.prepare(`UPDATE bills SET payment_details = ? WHERE id = ?`).run( - JSON.stringify({ method: 'upi', amount: 105, timestamp: '2026-07-01T11:30:00.000Z' }), - legacyBill.lastInsertRowid, -); -fixtureDb.prepare(` +`) + .run(legacyOrder.lastInsertRowid, legacyTaxBreakdown); +fixtureDb + .prepare(`UPDATE bills SET payment_details = ? WHERE id = ?`) + .run( + JSON.stringify({ method: 'upi', amount: 105, timestamp: '2026-07-01T11:30:00.000Z' }), + legacyBill.lastInsertRowid, + ); +fixtureDb + .prepare(` INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('cloud_sync_enabled', '0', '2026-08-01 12:00:00'), ('cloud_pending_store_id', 'old-pending-id', '2026-08-01 12:00:00'), ('cloud_server_url', 'https://example.test/', '2026-08-01 12:00:00') -`).run(); -fixtureDb.prepare(` +`) + .run(); +fixtureDb + .prepare(` INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('cloud_orders_enabled', '0', '2026-08-01T12:00:00.000Z') -`).run(); +`) + .run(); // Pre-v45 era rows written by the old `now()` (ISO-8601 with T/Z/ms). Migration // v45 must normalize these to the space form so range/order/compare operations // see one consistent format. -const isoOrder = fixtureDb.prepare(` +const isoOrder = fixtureDb + .prepare(` INSERT INTO orders (order_number, subtotal, tax_amount, total, created_at, updated_at, cooking_started_at, ready_at) VALUES ('ORD-ISO-TS', 10, 0, 10, '2026-07-01T10:00:00.123Z', '2026-07-01T10:00:00.123Z', '2026-07-01T10:05:00.000Z', '2026-07-01T10:12:00.000Z') -`).run(); -fixtureDb.prepare(` +`) + .run(); +fixtureDb + .prepare(` INSERT INTO bills (bill_number, order_id, subtotal, tax_amount, total, paid_at, created_at, updated_at) VALUES ('INV-ISO-TS', ?, 10, 0, 10, '2026-07-01T11:30:00.000Z', '2026-07-01T11:00:00.000Z', '2026-07-01T11:30:00.000Z') -`).run(isoOrder.lastInsertRowid); +`) + .run(isoOrder.lastInsertRowid); fixtureDb.close(); const mockApp = { @@ -93,8 +113,10 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { }; function isNativeAbiMismatch(error: any): boolean { - return error?.code === 'ERR_DLOPEN_FAILED' - && String(error?.message || '').includes('NODE_MODULE_VERSION'); + return ( + error?.code === 'ERR_DLOPEN_FAILED' && + String(error?.message || '').includes('NODE_MODULE_VERSION') + ); } function main() { @@ -102,7 +124,12 @@ function main() { console.log('='.repeat(60)); const { - initDatabase, getDatabase, closeDatabase, getCurrentSchemaVersion, buildIdealSchemaDb, MIGRATIONS, + initDatabase, + getDatabase, + closeDatabase, + getCurrentSchemaVersion, + buildIdealSchemaDb, + MIGRATIONS, } = require('../main/db'); const { runHealthCheck } = require('../main/services/schema-health'); @@ -114,7 +141,11 @@ function main() { (migration: any) => migration.name === 'add_charges_breakdown_and_custom_charges_setting', ); assert.ok(chargeMigrationIndex > 0, 'the charge snapshot migration is registered'); - assert.equal(MIGRATIONS[chargeMigrationIndex].version, 99, 'the charge snapshot migration is v99'); + assert.equal( + MIGRATIONS[chargeMigrationIndex].version, + 99, + 'the charge snapshot migration is v99', + ); const migrationsFromCharges = MIGRATIONS.splice(chargeMigrationIndex); try { try { @@ -126,155 +157,296 @@ function main() { } throw new Error(`migrating a real old install (v1.5.0 era) crashed: ${error.message}`); } - assert.equal(getCurrentSchemaVersion(), 98, 'the old install reaches v98 before the charge snapshot migration'); + assert.equal( + getCurrentSchemaVersion(), + 98, + 'the old install reaches v98 before the charge snapshot migration', + ); const beforeChargeSnapshot = getDatabase(); - const orderColumns = beforeChargeSnapshot.prepare('PRAGMA table_info(orders)').all().map((column: any) => column.name); - const billColumns = beforeChargeSnapshot.prepare('PRAGMA table_info(bills)').all().map((column: any) => column.name); - assert.equal(orderColumns.includes('charges_breakdown'), false, 'v98 orders do not yet have charge snapshots'); - assert.equal(billColumns.includes('charges_breakdown'), false, 'v98 bills do not yet have charge snapshots'); + const orderColumns = beforeChargeSnapshot + .prepare('PRAGMA table_info(orders)') + .all() + .map((column: any) => column.name); + const billColumns = beforeChargeSnapshot + .prepare('PRAGMA table_info(bills)') + .all() + .map((column: any) => column.name); + assert.equal( + orderColumns.includes('charges_breakdown'), + false, + 'v98 orders do not yet have charge snapshots', + ); + assert.equal( + billColumns.includes('charges_breakdown'), + false, + 'v98 bills do not yet have charge snapshots', + ); const legacyTotal = 120; - beforeChargeSnapshot.prepare(` + beforeChargeSnapshot + .prepare(` UPDATE orders SET service_charge = 12, packaging_charge = 3, total = ? WHERE id = ? - `).run(legacyTotal, legacyOrder.lastInsertRowid); - const paymentDetails = JSON.parse((beforeChargeSnapshot.prepare( - `SELECT payment_details FROM bills WHERE id = ?`, - ).get(legacyBill.lastInsertRowid) as { payment_details: string }).payment_details); + `) + .run(legacyTotal, legacyOrder.lastInsertRowid); + const paymentDetails = JSON.parse( + ( + beforeChargeSnapshot + .prepare(`SELECT payment_details FROM bills WHERE id = ?`) + .get(legacyBill.lastInsertRowid) as { payment_details: string } + ).payment_details, + ); paymentDetails.amount = legacyTotal; - beforeChargeSnapshot.prepare(` + beforeChargeSnapshot + .prepare(` UPDATE bills SET service_charge = 12, packaging_charge = 3, total = ?, payment_details = ? WHERE id = ? - `).run(legacyTotal, JSON.stringify(paymentDetails), legacyBill.lastInsertRowid); + `) + .run(legacyTotal, JSON.stringify(paymentDetails), legacyBill.lastInsertRowid); - const existingChargeSetting = JSON.stringify([{ - id: 'upgrade-fee', - name: 'Upgrade fee', - type: 'fixed', - value: 4, - calculation_basis: 'gross', - order_types: ['dine_in'], - is_optional: false, - is_default_active: true, - is_active: true, - }]); - beforeChargeSnapshot.prepare(` + const existingChargeSetting = JSON.stringify([ + { + id: 'upgrade-fee', + name: 'Upgrade fee', + type: 'fixed', + value: 4, + calculation_basis: 'gross', + order_types: ['dine_in'], + is_optional: false, + is_default_active: true, + is_active: true, + }, + ]); + beforeChargeSnapshot + .prepare(` INSERT INTO settings (key, value, updated_at) VALUES ('custom_charges', ?, '2026-08-01 12:00:00') ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at - `).run(existingChargeSetting); + `) + .run(existingChargeSetting); // A cart held before charge selections were persisted: it must keep its // customer, notes, items, and identity across the column-add migration. - beforeChargeSnapshot.prepare(` + beforeChargeSnapshot + .prepare(` INSERT OR IGNORE INTO customers (id, name, phone, created_at, updated_at) VALUES ('cust-legacy-hold', 'Legacy Hold Customer', '+10000000001', '2026-07-01 09:00:00', '2026-07-01 09:00:00') - `).run(); - beforeChargeSnapshot.prepare(` + `) + .run(); + beforeChargeSnapshot + .prepare(` INSERT INTO held_orders (id, table_id, items, customer_id, guest_count, order_notes, created_at, updated_at) VALUES ('hold-legacy-1', 'tbl-legacy-4', ?, 'cust-legacy-hold', 4, 'Extra napkins', '2026-07-01 09:05:00', '2026-07-01 09:05:00') - `).run(legacyHeldItems); + `) + .run(legacyHeldItems); } finally { closeDatabase(); MIGRATIONS.push(...migrationsFromCharges); } initDatabase(); - console.log(' ✓ an old (pre-migration-array) install migrates through to the latest schema without crashing'); + console.log( + ' ✓ an old (pre-migration-array) install migrates through to the latest schema without crashing', + ); const db = getDatabase(); - assert.equal(db.prepare("SELECT value FROM settings WHERE key = 'cloud_sync_enabled'").get().value, '1', - 'seed-written cloud sync defaults are flipped on during upgrade'); - assert.equal(db.prepare("SELECT COUNT(*) AS count FROM settings WHERE key = 'cloud_pending_store_id'").get().count, 0, - 'pending registration state is removed during upgrade'); - assert.equal(db.prepare("SELECT value FROM settings WHERE key = 'cloud_orders_enabled'").get().value, '0', - 'explicitly edited settings remain unchanged during upgrade'); - assert.equal(db.prepare("SELECT value FROM settings WHERE key = 'taxes_enabled'").get().value, 'false', - 'taxes remain off until the merchant enables them'); - assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'support_ticket_outbox'").get(), - 'support ticket outbox exists after upgrade'); - console.log(' ✓ v40/v41 preserves deliberate settings, flips untouched cloud sync, and creates the support outbox'); + assert.equal( + db.prepare("SELECT value FROM settings WHERE key = 'cloud_sync_enabled'").get().value, + '1', + 'seed-written cloud sync defaults are flipped on during upgrade', + ); + assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM settings WHERE key = 'cloud_pending_store_id'").get() + .count, + 0, + 'pending registration state is removed during upgrade', + ); + assert.equal( + db.prepare("SELECT value FROM settings WHERE key = 'cloud_orders_enabled'").get().value, + '0', + 'explicitly edited settings remain unchanged during upgrade', + ); + assert.equal( + db.prepare("SELECT value FROM settings WHERE key = 'taxes_enabled'").get().value, + 'false', + 'taxes remain off until the merchant enables them', + ); + assert.ok( + db + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'support_ticket_outbox'", + ) + .get(), + 'support ticket outbox exists after upgrade', + ); + console.log( + ' ✓ v40/v41 preserves deliberate settings, flips untouched cloud sync, and creates the support outbox', + ); const ideal = buildIdealSchemaDb(); const latestSchemaVersion = ideal.pragma('user_version', { simple: true }) as number; - assert.equal(getCurrentSchemaVersion(), latestSchemaVersion, - 'migrated old install reaches the same schema version as a fresh install'); - const tableColumns = db.prepare('PRAGMA table_info(tables)').all().map((column: any) => column.name); - assert.ok(tableColumns.includes('reservation_customer_id'), 'reservation customer association exists after upgrading an old install'); - assert.ok(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'clear_table_reservation_customer_on_status_change'").get(), - 'reservation customer cleanup trigger exists after upgrading an old install'); - const idealHeldColumns = ideal.prepare('PRAGMA table_info(held_orders)').all() as { name: string; notnull: number; dflt_value: string }[]; + assert.equal( + getCurrentSchemaVersion(), + latestSchemaVersion, + 'migrated old install reaches the same schema version as a fresh install', + ); + const tableColumns = db + .prepare('PRAGMA table_info(tables)') + .all() + .map((column: any) => column.name); + assert.ok( + tableColumns.includes('reservation_customer_id'), + 'reservation customer association exists after upgrading an old install', + ); + assert.ok( + db + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = 'clear_table_reservation_customer_on_status_change'", + ) + .get(), + 'reservation customer cleanup trigger exists after upgrading an old install', + ); + const idealHeldColumns = ideal.prepare('PRAGMA table_info(held_orders)').all() as { + name: string; + notnull: number; + dflt_value: string; + }[]; for (const column of ['waived_charge_ids', 'opted_in_charge_ids'] as const) { const definition = idealHeldColumns.find((candidate) => candidate.name === column); assert.ok(definition, `a fresh install declares held_orders.${column}`); assert.equal(definition!.notnull, 1, `fresh held_orders.${column} is NOT NULL`); - assert.equal(definition!.dflt_value, "'[]'", `fresh held_orders.${column} defaults to an empty selection list`); + assert.equal( + definition!.dflt_value, + "'[]'", + `fresh held_orders.${column} defaults to an empty selection list`, + ); } ideal.close(); assert.equal( - (db.prepare("SELECT value FROM settings WHERE key = 'split_checks_enabled'").get() as { value: string }).value, + ( + db.prepare("SELECT value FROM settings WHERE key = 'split_checks_enabled'").get() as { + value: string; + } + ).value, 'false', 'existing stores receive the opt-in split-check default during upgrade', ); console.log(' ✓ existing stores receive split checks disabled by default'); assert.equal( - (db.prepare("SELECT value FROM settings WHERE key = 'require_kitchen_delivered_before_settlement'").get() as { value: string }).value, + ( + db + .prepare( + "SELECT value FROM settings WHERE key = 'require_kitchen_delivered_before_settlement'", + ) + .get() as { value: string } + ).value, 'false', 'existing stores receive kitchen delivery enforcement disabled by default during upgrade', ); // #447: migration v72 upgrades the persisted value to the structured // selection identity; the bare string keeps resolving during transition. assert.deepEqual( - JSON.parse((db.prepare("SELECT value FROM settings WHERE key = 'bill_template'").get() as { value: string }).value), + JSON.parse( + ( + db.prepare("SELECT value FROM settings WHERE key = 'bill_template'").get() as { + value: string; + } + ).value, + ), { source: 'core', id: 'classic' }, 'existing stores receive the classic bill template during upgrade, upgraded to the structured selection identity', ); assert.equal( - (db.prepare("SELECT value FROM settings WHERE key = 'bill_footer_message'").get() as { value: string }).value, + ( + db.prepare("SELECT value FROM settings WHERE key = 'bill_footer_message'").get() as { + value: string; + } + ).value, '', 'existing stores receive an empty bill footer during upgrade', ); console.log(' ✓ existing stores receive bill template defaults without requiring a first save'); - const migratedUpi = db.prepare(` + const migratedUpi = db + .prepare(` SELECT pm.id, pm.name, b.payment_details FROM bills b JOIN payment_methods pm ON pm.id = json_extract(b.payment_details, '$.payment_method_id') WHERE b.bill_number = 'INV-LEGACY-TAX' - `).get() as { id: number; name: string; payment_details: string }; - assert.equal(migratedUpi.name, 'UPI', 'historical UPI is preserved as a custom method on upgrade'); - assert.equal(JSON.parse(migratedUpi.payment_details).method, 'UPI', 'historical UPI payment is linked to the preserved method'); + `) + .get() as { id: number; name: string; payment_details: string }; + assert.equal( + migratedUpi.name, + 'UPI', + 'historical UPI is preserved as a custom method on upgrade', + ); + assert.equal( + JSON.parse(migratedUpi.payment_details).method, + 'UPI', + 'historical UPI payment is linked to the preserved method', + ); console.log(' ✓ legacy UPI payments are preserved without seeding UPI on fresh installs'); // ── Migration v45: legacy ISO timestamps are normalized to the space form ─ - const isoOrderRow = db.prepare( - `SELECT created_at, updated_at, cooking_started_at, ready_at FROM orders WHERE order_number = 'ORD-ISO-TS'` - ).get() as { created_at: string; updated_at: string; cooking_started_at: string; ready_at: string }; + const isoOrderRow = db + .prepare( + `SELECT created_at, updated_at, cooking_started_at, ready_at FROM orders WHERE order_number = 'ORD-ISO-TS'`, + ) + .get() as { + created_at: string; + updated_at: string; + cooking_started_at: string; + ready_at: string; + }; assert.deepEqual( - [isoOrderRow.created_at, isoOrderRow.updated_at, isoOrderRow.cooking_started_at, isoOrderRow.ready_at], + [ + isoOrderRow.created_at, + isoOrderRow.updated_at, + isoOrderRow.cooking_started_at, + isoOrderRow.ready_at, + ], ['2026-07-01 10:00:00', '2026-07-01 10:00:00', '2026-07-01 10:05:00', '2026-07-01 10:12:00'], 'v45 normalizes legacy ISO order timestamps to the space form (second precision)', ); assert.deepEqual( - { ...db.prepare(`SELECT expected_payment_method, delivery_note FROM orders WHERE order_number = 'ORD-ISO-TS'`).get() as object }, + { + ...(db + .prepare( + `SELECT expected_payment_method, delivery_note FROM orders WHERE order_number = 'ORD-ISO-TS'`, + ) + .get() as object), + }, { expected_payment_method: null, delivery_note: null }, 'v103 keeps existing orders, reading as an unknown expected method with no courier note', ); - const isoBillRow = db.prepare( - `SELECT created_at, updated_at, paid_at FROM bills WHERE bill_number = 'INV-ISO-TS'` - ).get() as { created_at: string; updated_at: string; paid_at: string }; + const isoBillRow = db + .prepare(`SELECT created_at, updated_at, paid_at FROM bills WHERE bill_number = 'INV-ISO-TS'`) + .get() as { created_at: string; updated_at: string; paid_at: string }; assert.deepEqual( [isoBillRow.created_at, isoBillRow.updated_at, isoBillRow.paid_at], ['2026-07-01 11:00:00', '2026-07-01 11:30:00', '2026-07-01 11:30:00'], 'v45 normalizes legacy ISO bill timestamps to the space form', ); assert.ok( - db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_bills_paid_at'`).get(), + db + .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_bills_paid_at'`) + .get(), 'v45 creates idx_bills_paid_at for the payment-method breakdown scan', ); console.log(' ✓ legacy ISO timestamps are normalized to the space form (v45)'); - const customerColumns = db.prepare(`PRAGMA table_info(customers)`).all().map((c: any) => c.name); - assert.ok(customerColumns.includes('country_code'), 'customers.country_code exists after migrating an old install'); - assert.ok(customerColumns.includes('tag_counts'), 'customers.tag_counts exists after migrating an old install'); - console.log(' ✓ customers.country_code and customers.tag_counts are present (the 9c92409 regression)'); + const customerColumns = db + .prepare(`PRAGMA table_info(customers)`) + .all() + .map((c: any) => c.name); + assert.ok( + customerColumns.includes('country_code'), + 'customers.country_code exists after migrating an old install', + ); + assert.ok( + customerColumns.includes('tag_counts'), + 'customers.tag_counts exists after migrating an old install', + ); + console.log( + ' ✓ customers.country_code and customers.tag_counts are present (the 9c92409 regression)', + ); const requiredTaxTables = [ 'country_packs', @@ -303,9 +475,15 @@ function main() { bills: ['tax_snapshot'], }; for (const [table, expected] of Object.entries(expectedTaxColumns)) { - const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((column: any) => column.name); + const columns = db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((column: any) => column.name); for (const column of expected) { - assert.ok(columns.includes(column), `${table}.${column} exists after upgrading an old install`); + assert.ok( + columns.includes(column), + `${table}.${column} exists after upgrading an old install`, + ); } } console.log(' ✓ old installs receive every Phase 1 tax table and guarded additive column'); @@ -315,7 +493,8 @@ function main() { 'old installs register only the generic tax pack during upgrade', ); assert.equal( - (db.prepare('SELECT COUNT(*) AS count FROM country_pack_versions').get() as { count: number }).count, + (db.prepare('SELECT COUNT(*) AS count FROM country_pack_versions').get() as { count: number }) + .count, 1, 'old installs register only the generic tax pack version during upgrade', ); @@ -328,8 +507,9 @@ function main() { // resolved category only. An upgraded install must not silently start // charging tax again based on stale legacy columns. const legacyProduct = db.prepare('SELECT * FROM products LIMIT 1').get() as any; - db.prepare(`UPDATE products SET tax_category_id = NULL, tax_type = 'exclusive', tax_rate = 18 WHERE id = ?`) - .run(legacyProduct.id); + db.prepare( + `UPDATE products SET tax_category_id = NULL, tax_type = 'exclusive', tax_rate = 18 WHERE id = ?`, + ).run(legacyProduct.id); const { calculateItemTax } = require('../main/services/tax'); const legacyTax = calculateItemTax( // taxes_enabled: true here, deliberately, even though this install's @@ -341,17 +521,27 @@ function main() { 100, null, ); - assert.equal(legacyTax.tax_amount, 0, 'an upgraded product with no resolved tax category charges no tax'); + assert.equal( + legacyTax.tax_amount, + 0, + 'an upgraded product with no resolved tax category charges no tax', + ); assert.deepEqual(legacyTax.tax_breakdown, []); - console.log(' ✓ an upgraded product with stale legacy tax_type/tax_rate charges no tax until categorized'); - const preservedOrder = db.prepare(` + console.log( + ' ✓ an upgraded product with stale legacy tax_type/tax_rate charges no tax until categorized', + ); + const preservedOrder = db + .prepare(` SELECT tax_amount, tax_breakdown, tax_snapshot, service_charge, packaging_charge, total, charges_breakdown FROM orders WHERE order_number = 'ORD-LEGACY-TAX' - `).get() as any; - const preservedBill = db.prepare(` + `) + .get() as any; + const preservedBill = db + .prepare(` SELECT tax_amount, tax_breakdown, tax_snapshot, service_charge, packaging_charge, total, charges_breakdown FROM bills WHERE bill_number = 'INV-LEGACY-TAX' - `).get() as any; + `) + .get() as any; assert.deepEqual( { tax_amount: preservedOrder.tax_amount, tax_breakdown: preservedOrder.tax_breakdown }, { tax_amount: 5, tax_breakdown: legacyTaxBreakdown }, @@ -362,7 +552,10 @@ function main() { ); assert.equal(preservedOrder.tax_snapshot, null); assert.equal(preservedBill.tax_snapshot, null); - for (const [row, name] of [[preservedOrder, 'order'], [preservedBill, 'bill']] as const) { + for (const [row, name] of [ + [preservedOrder, 'order'], + [preservedBill, 'bill'], + ] as const) { assert.deepEqual( { service_charge: row.service_charge, @@ -374,68 +567,124 @@ function main() { `v99 preserves the legacy ${name} total and manual charge columns`, ); } - const preservedChargeSetting = db.prepare( - `SELECT value FROM settings WHERE key = 'custom_charges'`, - ).get() as { value: string }; + const preservedChargeSetting = db + .prepare(`SELECT value FROM settings WHERE key = 'custom_charges'`) + .get() as { value: string }; assert.deepEqual( JSON.parse(preservedChargeSetting.value), - [{ - id: 'upgrade-fee', - name: 'Upgrade fee', - type: 'fixed', - value: 4, - calculation_basis: 'gross', - order_types: ['dine_in'], - is_optional: false, - is_default_active: true, - is_active: true, - }], + [ + { + id: 'upgrade-fee', + name: 'Upgrade fee', + type: 'fixed', + value: 4, + calculation_basis: 'gross', + order_types: ['dine_in'], + is_optional: false, + is_default_active: true, + is_active: true, + }, + ], 'v99 preserves an existing configured charge', ); - console.log(' ✓ v99 preserves legacy order/bill totals, manual charge columns, and configured charge settings'); + console.log( + ' ✓ v99 preserves legacy order/bill totals, manual charge columns, and configured charge settings', + ); console.log(' ✓ existing products, orders, bills, and legacy tax breakdowns are preserved'); // ── The migrated old install must match the ideal (fresh) schema exactly ─ const report = runHealthCheck(); - assert.equal(report.findings.length, 0, - `a migrated old install should match a fresh install's schema exactly, found: ${JSON.stringify(report.findings)}`); - console.log(' ✓ runHealthCheck() reports zero drift between the migrated old install and a fresh install'); + assert.equal( + report.findings.length, + 0, + `a migrated old install should match a fresh install's schema exactly, found: ${JSON.stringify(report.findings)}`, + ); + console.log( + ' ✓ runHealthCheck() reports zero drift between the migrated old install and a fresh install', + ); for (const table of ['cash_drawer_movements', 'refunds'] as const) { - const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((column: any) => column.name); - assert.ok(columns.includes('cash_session_id'), `${table}.cash_session_id exists after upgrading an old install`); + const columns = db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((column: any) => column.name); + assert.ok( + columns.includes('cash_session_id'), + `${table}.cash_session_id exists after upgrading an old install`, + ); } console.log(' ✓ cash ownership columns exist after upgrading an old install'); // ── Held carts survive the charge-selection columns ───────────────────── - const heldOrderColumns = db.prepare('PRAGMA table_info(held_orders)').all() + const heldOrderColumns = db + .prepare('PRAGMA table_info(held_orders)') + .all() .map((column: any) => column.name); for (const column of ['waived_charge_ids', 'opted_in_charge_ids'] as const) { - assert.ok(heldOrderColumns.includes(column), `held_orders.${column} exists after upgrading an old install`); + assert.ok( + heldOrderColumns.includes(column), + `held_orders.${column} exists after upgrading an old install`, + ); } - const preservedHold = db.prepare(` + const preservedHold = db + .prepare(` SELECT id, table_id, items, customer_id, guest_count, order_notes, waived_charge_ids, opted_in_charge_ids, created_at, updated_at FROM held_orders WHERE id = 'hold-legacy-1' - `).get() as any; + `) + .get() as any; assert.ok(preservedHold, 'the held cart saved before the migration still exists'); assert.equal(preservedHold.table_id, 'tbl-legacy-4', 'the held cart keeps its table'); - assert.equal(JSON.parse(preservedHold.items)[0].id, 'line-legacy-1', 'the held cart keeps its items'); - assert.equal(JSON.parse(preservedHold.items)[0].special_instructions, 'oat', 'held item notes survive'); + assert.equal( + JSON.parse(preservedHold.items)[0].id, + 'line-legacy-1', + 'the held cart keeps its items', + ); + assert.equal( + JSON.parse(preservedHold.items)[0].special_instructions, + 'oat', + 'held item notes survive', + ); assert.equal(preservedHold.customer_id, 'cust-legacy-hold', 'the held cart keeps its customer'); assert.equal(preservedHold.guest_count, 4, 'the held cart keeps its guest count'); assert.equal(preservedHold.order_notes, 'Extra napkins', 'the held cart keeps its notes'); - assert.equal(preservedHold.created_at, '2026-07-01 09:05:00', 'the held cart keeps its created timestamp'); - assert.equal(preservedHold.updated_at, '2026-07-01 09:05:00', 'the held cart keeps its updated timestamp'); - assert.equal(preservedHold.waived_charge_ids, '[]', 'a legacy hold defaults to no waived charges'); - assert.equal(preservedHold.opted_in_charge_ids, '[]', 'a legacy hold defaults to no opted-in charges'); - assert.deepEqual(db.pragma('integrity_check'), [{ integrity_check: 'ok' }], 'integrity_check passes after the held-cart migration'); + assert.equal( + preservedHold.created_at, + '2026-07-01 09:05:00', + 'the held cart keeps its created timestamp', + ); + assert.equal( + preservedHold.updated_at, + '2026-07-01 09:05:00', + 'the held cart keeps its updated timestamp', + ); + assert.equal( + preservedHold.waived_charge_ids, + '[]', + 'a legacy hold defaults to no waived charges', + ); + assert.equal( + preservedHold.opted_in_charge_ids, + '[]', + 'a legacy hold defaults to no opted-in charges', + ); + assert.deepEqual( + db.pragma('integrity_check'), + [{ integrity_check: 'ok' }], + 'integrity_check passes after the held-cart migration', + ); console.log(' ✓ v105 preserves a held cart while adding defaulted charge-selection columns'); // ── The columns must actually be usable, not just present ─────────────── - const customerId = db.prepare(`SELECT id FROM customers LIMIT 1`).get() as { id: string } | undefined; + const customerId = db.prepare(`SELECT id FROM customers LIMIT 1`).get() as + | { id: string } + | undefined; if (customerId) { - db.prepare(`UPDATE customers SET country_code = '+1', tag_counts = '{}' WHERE id = ?`).run(customerId.id); - const row = db.prepare(`SELECT country_code, tag_counts FROM customers WHERE id = ?`).get(customerId.id) as any; + db.prepare(`UPDATE customers SET country_code = '+1', tag_counts = '{}' WHERE id = ?`).run( + customerId.id, + ); + const row = db + .prepare(`SELECT country_code, tag_counts FROM customers WHERE id = ?`) + .get(customerId.id) as any; assert.equal(row.country_code, '+1'); assert.equal(row.tag_counts, '{}'); console.log(' ✓ the backfilled columns are readable and writable'); @@ -480,35 +729,47 @@ function main() { initDatabase(); assert.equal(getCurrentSchemaVersion(), latestSchemaVersion); assert.equal(runHealthCheck().findings.length, 0); - const preservedPack = getDatabase().prepare(` + const preservedPack = getDatabase() + .prepare(` SELECT active_version_id, status FROM country_packs WHERE id = ? - `).get(existingCountryPack.id) as { active_version_id: string; status: string }; + `) + .get(existingCountryPack.id) as { active_version_id: string; status: string }; assert.deepEqual( preservedPack, { active_version_id: preservedVersionId, status: 'active' }, 'an already-active country pack survives the changed bundle migration untouched', ); assert.ok( - getDatabase().prepare('SELECT 1 FROM country_pack_versions WHERE id = ?').get(preservedVersionId), + getDatabase() + .prepare('SELECT 1 FROM country_pack_versions WHERE id = ?') + .get(preservedVersionId), 'the previously installed active pack version is preserved', ); assert.equal( - (getDatabase().prepare("SELECT value FROM settings WHERE key = 'split_checks_enabled'").get() as { value: string }).value, + ( + getDatabase() + .prepare("SELECT value FROM settings WHERE key = 'split_checks_enabled'") + .get() as { value: string } + ).value, 'true', 'v60 preserves an existing split-check choice', ); - getDatabase().prepare(`UPDATE settings SET value = 'false' WHERE key = 'diagnostics_consent'`).run(); + getDatabase() + .prepare(`UPDATE settings SET value = 'false' WHERE key = 'diagnostics_consent'`) + .run(); getDatabase().pragma('user_version = 46'); closeDatabase(); initDatabase(); - const diagnosticsSetting = getDatabase().prepare( - `SELECT value FROM settings WHERE key = 'diagnostics_consent'`, - ).get() as { value: string }; + const diagnosticsSetting = getDatabase() + .prepare(`SELECT value FROM settings WHERE key = 'diagnostics_consent'`) + .get() as { value: string }; assert.equal(diagnosticsSetting.value, 'false', 'v47 preserves an existing diagnostics opt-out'); console.log(' ✓ reopening is idempotent and preserves an already-active country pack'); - const reopenedHeld = getDatabase().prepare( - `SELECT waived_charge_ids, opted_in_charge_ids FROM held_orders WHERE id = 'hold-legacy-1'`, - ).get() as { waived_charge_ids: string; opted_in_charge_ids: string }; + const reopenedHeld = getDatabase() + .prepare( + `SELECT waived_charge_ids, opted_in_charge_ids FROM held_orders WHERE id = 'hold-legacy-1'`, + ) + .get() as { waived_charge_ids: string; opted_in_charge_ids: string }; assert.deepEqual( reopenedHeld, { waived_charge_ids: '[]', opted_in_charge_ids: '[]' }, @@ -535,65 +796,201 @@ function main() { MIGRATIONS[MIGRATIONS.length - 1].version, 'the store is fully migrated before rewinding the identity column', ); - preIdentity.prepare('UPDATE orders SET expected_payment_method = ? WHERE id = ?') + preIdentity + .prepare('UPDATE orders SET expected_payment_method = ? WHERE id = ?') .run('pending', legacyOrder.lastInsertRowid); - preIdentity.prepare('UPDATE orders SET expected_payment_method = ? WHERE id = ?') + preIdentity + .prepare('UPDATE orders SET expected_payment_method = ? WHERE id = ?') .run('Store Credit', isoOrder.lastInsertRowid); preIdentity.exec('ALTER TABLE orders DROP COLUMN expected_payment_method_id'); preIdentity.pragma('user_version = 105'); assert.ok( - !(preIdentity.prepare('PRAGMA table_info(orders)').all() as { name: string }[]) - .some((column) => column.name === 'expected_payment_method_id'), + !(preIdentity.prepare('PRAGMA table_info(orders)').all() as { name: string }[]).some( + (column) => column.name === 'expected_payment_method_id', + ), 'the rewind reproduces a store that predates the identity column', ); closeDatabase(); const migrationsFromIdentity = MIGRATIONS.splice(identityMigrationIndex); try { initDatabase(); - assert.equal(getCurrentSchemaVersion(), 105, 'the store stops at v105 without the identity column'); + assert.equal( + getCurrentSchemaVersion(), + 105, + 'the store stops at v105 without the identity column', + ); } finally { MIGRATIONS.push(...migrationsFromIdentity); } closeDatabase(); initDatabase(); const identityDb = getDatabase(); - const preIdentitySnapshot = identityDb.prepare( - 'SELECT expected_payment_method FROM orders WHERE id = ?', - ).get(legacyOrder.lastInsertRowid) as { expected_payment_method: string | null }; - assert.equal(preIdentitySnapshot.expected_payment_method, 'pending', - 'the pre-migration sentinel name is still on disk before the identity column exists'); - const identityOrders = identityDb.prepare( - 'SELECT order_number, expected_payment_method, expected_payment_method_id FROM orders ORDER BY id', - ).all() as Array<{ order_number: string; expected_payment_method: string | null; expected_payment_method_id: number | null }>; + const preIdentitySnapshot = identityDb + .prepare('SELECT expected_payment_method FROM orders WHERE id = ?') + .get(legacyOrder.lastInsertRowid) as { expected_payment_method: string | null }; + assert.equal( + preIdentitySnapshot.expected_payment_method, + 'pending', + 'the pre-migration sentinel name is still on disk before the identity column exists', + ); + const identityOrders = identityDb + .prepare( + 'SELECT order_number, expected_payment_method, expected_payment_method_id FROM orders ORDER BY id', + ) + .all() as Array<{ + order_number: string; + expected_payment_method: string | null; + expected_payment_method_id: number | null; + }>; const sentinelOrder = identityOrders.find((row) => row.order_number === 'ORD-LEGACY-TAX'); - assert.equal(sentinelOrder?.expected_payment_method, 'pending', - 'a legacy sentinel name snapshot survives the identity migration unchanged'); - assert.equal(sentinelOrder?.expected_payment_method_id, null, - 'the legacy `pending` string is never guessed into a custom method identity'); + assert.equal( + sentinelOrder?.expected_payment_method, + 'pending', + 'a legacy sentinel name snapshot survives the identity migration unchanged', + ); + assert.equal( + sentinelOrder?.expected_payment_method_id, + null, + 'the legacy `pending` string is never guessed into a custom method identity', + ); const customNameOrder = identityOrders.find((row) => row.order_number === 'ORD-ISO-TS'); - assert.equal(customNameOrder?.expected_payment_method, 'Store Credit', - 'a legacy custom name snapshot survives the identity migration unchanged'); - assert.equal(customNameOrder?.expected_payment_method_id, null, - 'a legacy custom name is not retroactively linked to a configured method row'); - const orderColumnsAfterIdentity = identityDb.prepare('PRAGMA table_info(orders)').all() as - { name: string; notnull: number; dflt_value: string | null }[]; - const identityColumn = orderColumnsAfterIdentity.find((column) => column.name === 'expected_payment_method_id'); - assert.ok(identityColumn, 'orders.expected_payment_method_id exists after upgrading an old install'); - assert.equal(identityColumn!.notnull, 0, 'the identity column is nullable so legacy rows stay unambiguous'); + assert.equal( + customNameOrder?.expected_payment_method, + 'Store Credit', + 'a legacy custom name snapshot survives the identity migration unchanged', + ); + assert.equal( + customNameOrder?.expected_payment_method_id, + null, + 'a legacy custom name is not retroactively linked to a configured method row', + ); + const orderColumnsAfterIdentity = identityDb.prepare('PRAGMA table_info(orders)').all() as { + name: string; + notnull: number; + dflt_value: string | null; + }[]; + const identityColumn = orderColumnsAfterIdentity.find( + (column) => column.name === 'expected_payment_method_id', + ); assert.ok( - identityColumn!.dflt_value === null || String(identityColumn!.dflt_value).toUpperCase() === 'NULL', + identityColumn, + 'orders.expected_payment_method_id exists after upgrading an old install', + ); + assert.equal( + identityColumn!.notnull, + 0, + 'the identity column is nullable so legacy rows stay unambiguous', + ); + assert.ok( + identityColumn!.dflt_value === null || + String(identityColumn!.dflt_value).toUpperCase() === 'NULL', `the identity column has no default identity, found ${String(identityColumn!.dflt_value)}`, ); - assert.deepEqual(identityDb.pragma('integrity_check'), [{ integrity_check: 'ok' }], - 'integrity_check passes after the identity migration'); + assert.deepEqual( + identityDb.pragma('integrity_check'), + [{ integrity_check: 'ok' }], + 'integrity_check passes after the identity migration', + ); const freshIdentityDb = buildIdealSchemaDb(); assert.ok( - (freshIdentityDb.prepare('PRAGMA table_info(orders)').all() as { name: string }[]) - .some((column) => column.name === 'expected_payment_method_id'), + (freshIdentityDb.prepare('PRAGMA table_info(orders)').all() as { name: string }[]).some( + (column) => column.name === 'expected_payment_method_id', + ), 'a fresh install declares orders.expected_payment_method_id', ); freshIdentityDb.close(); - console.log(' ✓ v106 adds a nullable expected-method identity without reinterpreting old snapshots'); + console.log( + ' ✓ v106 adds a nullable expected-method identity without reinterpreting old snapshots', + ); + closeDatabase(); + + // ── v120: per-staff cash sessions index migration preserves open shifts ── + const perStaffMigrationIndex = MIGRATIONS.findIndex( + (migration: any) => migration.name === 'add_per_staff_cash_sessions', + ); + assert.ok(perStaffMigrationIndex > 0, 'the per-staff cash sessions migration is registered'); + assert.equal( + MIGRATIONS[perStaffMigrationIndex].version, + 120, + 'the per-staff cash sessions migration is v120', + ); + initDatabase(); + const prePerStaffDb = getDatabase(); + prePerStaffDb.exec(` + DROP INDEX IF EXISTS cash_sessions_one_open_per_user; + CREATE UNIQUE INDEX IF NOT EXISTS cash_sessions_one_open + ON cash_sessions(status) WHERE status = 'open'; + `); + prePerStaffDb.prepare(`DELETE FROM settings WHERE key = 'one_shift_per_staff'`).run(); + prePerStaffDb.prepare(`DELETE FROM cash_sessions WHERE status = 'open'`).run(); + prePerStaffDb + .prepare(` + INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES ('cashier-upgrade', 'Upgrade Cashier', 'cashier-upgrade@test.local', 'hash', 'cashier', 1, '2026-10-28 08:00:00', '2026-10-28 08:00:00') + `) + .run(); + const preUpgradeOpenShift = prePerStaffDb + .prepare(` + INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status) + VALUES ('cashier-upgrade', 'Upgrade Cashier', '2026-10-28 08:00:00', 7500, 'open') + `) + .run(); + const preUpgradeShiftId = Number(preUpgradeOpenShift.lastInsertRowid); + prePerStaffDb.pragma('user_version = 119'); + closeDatabase(); + + initDatabase(); + const postPerStaffDb = getDatabase(); + assert.equal(getCurrentSchemaVersion(), latestSchemaVersion); + assert.equal( + postPerStaffDb + .prepare(`SELECT 1 FROM sqlite_master WHERE name = 'cash_sessions_one_open'`) + .get(), + undefined, + 'v120 drops the legacy global cash_sessions_one_open index', + ); + const perUserIdx = postPerStaffDb + .prepare(`SELECT sql FROM sqlite_master WHERE name = 'cash_sessions_one_open_per_user'`) + .get() as { sql: string } | undefined; + assert.ok( + perUserIdx && perUserIdx.sql.includes('opened_by') && perUserIdx.sql.includes('WHERE'), + 'v120 creates the per-user partial unique index cash_sessions_one_open_per_user', + ); + const upgradedOpenShift = postPerStaffDb + .prepare( + `SELECT id, opened_by, opened_by_name, opening_float_cents, status FROM cash_sessions WHERE id = ?`, + ) + .get(preUpgradeShiftId) as { + id: number; + opened_by: string; + opened_by_name: string; + opening_float_cents: number; + status: string; + }; + assert.deepEqual( + upgradedOpenShift, + { + id: preUpgradeShiftId, + opened_by: 'cashier-upgrade', + opened_by_name: 'Upgrade Cashier', + opening_float_cents: 7500, + status: 'open', + }, + 'upgrading a database with an open shift keeps it open and valid', + ); + const perStaffSetting = postPerStaffDb + .prepare(`SELECT value FROM settings WHERE key = 'one_shift_per_staff'`) + .get() as { value: string } | undefined; + assert.equal(perStaffSetting?.value, 'false', 'v120 seeds one_shift_per_staff to false'); + assert.equal( + runHealthCheck().findings.length, + 0, + 'runHealthCheck() reports zero drift after v120', + ); + postPerStaffDb.prepare(`DELETE FROM cash_sessions WHERE id = ?`).run(preUpgradeShiftId); + console.log( + ' ✓ v120 replaces cash_sessions_one_open with cash_sessions_one_open_per_user and preserves an open shift', + ); closeDatabase(); console.log('='.repeat(60)); From b82e6d682197f85da3ef4a4a0bb44c2fc8be56b5 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 12:15:00 -0400 Subject: [PATCH 14/22] no-mistakes(review): fix(cash): reset shift selection state and update v120 pins --- frontend/e2e/database-export-import.spec.ts | 2 +- .../src/components/dashboard/ShiftCloseModal.tsx | 5 +++++ frontend/src/hooks/useCashSession.ts | 15 +++++++++++++++ tests/backup-restore-production.test.ts | 4 ++-- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/database-export-import.spec.ts b/frontend/e2e/database-export-import.spec.ts index 007ac2603..1d110ca40 100644 --- a/frontend/e2e/database-export-import.spec.ts +++ b/frontend/e2e/database-export-import.spec.ts @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => { const contaminatedPayload = { app: 'FloDesktop', - schema_version: '119', + schema_version: '120', data: { settings: [], categories: [], diff --git a/frontend/src/components/dashboard/ShiftCloseModal.tsx b/frontend/src/components/dashboard/ShiftCloseModal.tsx index 4a22db223..e5f82a139 100644 --- a/frontend/src/components/dashboard/ShiftCloseModal.tsx +++ b/frontend/src/components/dashboard/ShiftCloseModal.tsx @@ -42,6 +42,7 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { printClosure, setSubmitError, closePreview, + setClosePreview, } = model; const targetSession = activeSession ?? session; const tPos = useTranslations('pos'); @@ -59,6 +60,8 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { setClosedResult(null); setSubmitError(null); setCountedInput(''); + setSelectedSessionId(null); + setClosePreview(null); setHasPrinted(false); } setCloseModalOpen(next); @@ -100,6 +103,8 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { const nextId = Number(e.target.value); if (Number.isInteger(nextId) && nextId > 0) { setSelectedSessionId(nextId); + setSubmitError(null); + setClosePreview(null); } }} className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground" diff --git a/frontend/src/hooks/useCashSession.ts b/frontend/src/hooks/useCashSession.ts index 936b7dee8..9f5eb5dd3 100644 --- a/frontend/src/hooks/useCashSession.ts +++ b/frontend/src/hooks/useCashSession.ts @@ -94,6 +94,17 @@ export function useCashSession() { const [closedResult, setClosedResult] = useState(null); const [printing, setPrinting] = useState(false); const [closePreview, setClosePreview] = useState(null); + const activeUserId = user?.id ?? null; + const [syncedUserId, setSyncedUserId] = useState(activeUserId); + if (syncedUserId !== activeUserId) { + setSyncedUserId(activeUserId); + setSelectedSessionId(null); + setClosePreview(null); + setSubmitError(null); + setClosedResult(null); + setCountedInput(''); + setFloatInput(''); + } // Pure fetcher shared by the mount effect and refresh(): performs the // request, returns data-or-null, never touches state itself. (The mount @@ -155,6 +166,8 @@ export function useCashSession() { if (cancelled || seq !== refreshSeq.current) return; setSession(data); setOpenSessions(allOpen); + setSelectedSessionId(null); + setClosePreview(null); setError(loadError); setLoading(false); }); @@ -237,6 +250,8 @@ export function useCashSession() { }); setClosedResult(res.data); setCountedInput(''); + setSelectedSessionId(null); + setClosePreview(null); toast.success(t('shiftClosed')); if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('flo-shift-changed')); await refresh(); diff --git a/tests/backup-restore-production.test.ts b/tests/backup-restore-production.test.ts index 347a96fa8..00e1b0d9b 100644 --- a/tests/backup-restore-production.test.ts +++ b/tests/backup-restore-production.test.ts @@ -973,8 +973,8 @@ async function run() { initDatabase(); assert.equal( getCurrentSchemaVersion(), - 119, - 'interrupted v114 replacement recovered and migrated to v119', + 120, + 'interrupted v114 replacement recovered and migrated to v120', ); assert.equal( fs.existsSync(journalPath), From f830d01d2fb4009ac4e67e3e93b400ad27435591 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 13:08:09 -0400 Subject: [PATCH 15/22] no-mistakes(document): Consolidate per-staff shift docs and fix lint warnings --- docs/architecture/business-time.md | 7 ++++--- docs/architecture/order-lifecycle.md | 7 ++++--- docs/reference/api.md | 18 +++++++++--------- docs/reference/roles-and-permissions.md | 23 +++++++++-------------- frontend/src/hooks/useCashSession.ts | 2 +- main/routes/cash-closures.ts | 4 ++-- main/routes/cash-sessions.ts | 3 ++- main/routes/printers.ts | 5 +++-- 8 files changed, 34 insertions(+), 35 deletions(-) diff --git a/docs/architecture/business-time.md b/docs/architecture/business-time.md index 5b29c5ee9..cd0d222ef 100644 --- a/docs/architecture/business-time.md +++ b/docs/architecture/business-time.md @@ -130,9 +130,10 @@ and close at most one shift of their own (`cash_sessions_one_open_per_user` on ` Cash payments, cash refunds, drawer movements, and cash expense entries attach to the authenticated actor's open shift (`getOpenSession(db, userId)`). Day close (`POST /api/cash-closures`) is blocked (`409 open_shift_blocks_day_close`) until all shifts opened on or before that business date are -closed, and reconciles the combined day totals across all shifts. Two concurrent shifts sharing a -single physical cash drawer are permitted, though individual shift drawer counts cannot be -physically isolated in a shared drawer. +closed, and reconciles the combined day totals across all shifts (summing per-staff shift opening +floats into the day's `opening_float` drawer movement). Two concurrent shifts sharing a single +physical cash drawer are permitted, though individual shift drawer counts cannot be physically +isolated in a shared drawer. ### Refunds diff --git a/docs/architecture/order-lifecycle.md b/docs/architecture/order-lifecycle.md index c8af957bc..3a0ec33fa 100644 --- a/docs/architecture/order-lifecycle.md +++ b/docs/architecture/order-lifecycle.md @@ -214,9 +214,10 @@ payments. Two gates sit between a valid request and the write: - **Shift gate.** When `require_open_shift` is on, a cash tender (including a custom method that - resolves to cash) needs an open shift, or the request is a `409`. The check runs after replay - detection, so a committed payment can be re-read with the same key after its shift closed. A cash - ledger line records the open `cash_session_id`. + resolves to cash) needs an open shift for the actor (see [business time](business-time.md#cash-closures)), + or the request is a `409`. The check runs after replay detection, so a committed payment can be + re-read with the same key after its shift closed. A cash ledger line records the open + `cash_session_id`. - **Kitchen-delivery gate.** When `require_kitchen_delivered_before_settlement` is on, `kds_enabled` is not `false`, and `billing_type` is not `prepaid`, paying while kitchen items are undelivered is a `409` with code `KITCHEN_ITEMS_UNDELIVERED` unless a manager PIN is supplied as `override_pin`. diff --git a/docs/reference/api.md b/docs/reference/api.md index fee2c58a7..0088feaed 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -436,7 +436,7 @@ recovery mutations stay usable without rounding. | Method | Path | Authorization | Parameters | Response | | ------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/context` | `expenses.view` | none | `{ currency_code, business_date, cash_session_open }`: store currency, store business date, and whether a shift is open. | +| `GET` | `/context` | `expenses.view` | none | `{ currency_code, business_date, cash_session_open }`: store currency, store business date, and whether a shift is open for the caller. | | `GET` | `/categories` | `expenses.view` | query: `?include_inactive` | `{ categories, truncated }`. Deactivated categories stay readable for history and never accept a new expense. | | `POST` | `/categories` | `expenses.manage` + `expenses.view`; header `Idempotency-Key` | body: `name`, optional `is_active` (defaults to `true`) | `201 { category }`. Names are unique after trimming, case-insensitively. | | `PATCH` | `/categories/:id` | `expenses.manage` + `expenses.view`; header `Idempotency-Key` | path: `id`; body: `name` and/or `is_active` | `{ category }`. Explicit values only - there is no toggle - and renaming never rewrites the category label snapshotted on existing expenses. | @@ -487,14 +487,14 @@ Router: `main/routes/payment-methods.ts`. Full path: `/api/payment-methods`. Router: `main/routes/cash-sessions.ts`. Full path: `/api/cash-sessions`. -When `one_shift_per_staff` is `'false'` (default), at most one shift may be open across the store. When `one_shift_per_staff` is `'true'`, each user may have at most one open shift (`cash_sessions_one_open_per_user`), and cash payments, cash refunds, drawer movements, and cash expense entries route to the authenticated actor's open shift (`getOpenSession(db, userId)`). Sharing a single physical cash drawer across concurrent shifts is supported, though individual drawer counts cannot be physically isolated when one drawer is shared. +See [business time](../architecture/business-time.md#cash-closures) for single-shift and per-staff shift (`one_shift_per_staff`) lifecycle and shared-drawer rules. | Method | Path | Authorization | Parameters | Response | | ------ | ------------ | --------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST` | `/open` | `ROLE_ACCESS.ownerManagerCashier` | body: `opening_float_cents` | Body `opening_float_cents` (integer minor units, defaults to 0). `409` (`session_already_open`) when a shift is already open (`one_shift_per_staff` off) or the caller already has an open shift (`one_shift_per_staff` on), or `409` (`day_already_closed`) when the current store business day is already closed. | -| `GET` | `/open` | `ROLE_ACCESS.ownerManagerCashier` | none | `{ sessions, one_shift_per_staff }`: open shifts visible to the caller (all open shifts for `owner`/`manager`, own open shift for `cashier`) with live `expected_cash_cents`. | -| `GET` | `/current` | `ROLE_ACCESS.ownerManagerCashier` | query: optional `?session_id`, `?user_id` | The open shift (for the caller by default, or the specified `session_id`/`user_id` for `owner`/`manager`; `403 session_owner_required` if a cashier requests another user's shift), or `null` when none is open. | -| `GET` | `/current/close-preview` | `ROLE_ACCESS.ownerManagerCashier` | query: optional `?session_id` | Informational carry-over preview of open/unpaid bills created during the shift window (`unpaid_in_shift` with `count`, `bill_count`, `pending_cents`, `sample`, and `would_block_close: false`). Defaults to the caller's open shift; `owner`/`manager` may pass `?session_id` (`403 session_owner_required` if a cashier previews another user's shift). | +| `GET` | `/open` | `ROLE_ACCESS.ownerManagerCashier` | none | `{ sessions, one_shift_per_staff }`: open shifts visible to the caller (all open shifts for `owner`/`manager`, own open shift for other roles) with live `expected_cash_cents`. | +| `GET` | `/current` | `ROLE_ACCESS.ownerManagerCashier` | query: optional `?session_id`, `?user_id` | The open shift (for the caller by default, or the specified `session_id`/`user_id` for `owner`/`manager`; `400 session_id_invalid` for a non-positive integer `session_id`; `403 session_owner_required` if a non-`owner`/`manager` requests another user's shift), or `null` when none is open. | +| `GET` | `/current/close-preview` | `ROLE_ACCESS.ownerManagerCashier` | query: optional `?session_id` | Informational carry-over preview of open/unpaid bills created during the shift window (`unpaid_in_shift` with `count`, `bill_count`, `pending_cents`, `sample`, and `would_block_close: false`). Defaults to the caller's open shift; `owner`/`manager` may pass `?session_id` (`400 session_id_invalid` for a non-positive integer `session_id`; `404 session_unavailable` when no open shift matches; `403 session_owner_required` if a non-`owner`/`manager` previews another user's shift). | | `POST` | `/:id/close` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `counted_cash_cents`, optional `notes` | Closes the shift; returns `{ closure_id, variance_cents, expected_cash_cents, counted_cash_cents }`. Open/unpaid orders from the shift remain open and carry over to the next shift while `expected_cash_cents` reconciles only cash collected during the shift. `409` when the shift is already closed (`session_already_closed`). `403` when a non-`owner`/`manager` attempts to close another user's shift (`session_owner_required`). | ### Cash closures @@ -504,9 +504,9 @@ Router: `main/routes/cash-closures.ts`. Full path: `/api/cash-closures`. | Method | Path | Authorization | Parameters | Response | | ------ | --------------------- | --------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/movements` | `ROLE_ACCESS.ownerManagerCashier` | query: `?business_date` | Append-only cash-drawer history, newest first, including soft-voided rows. | -| `POST` | `/movements` | `ROLE_ACCESS.ownerManagerCashier` | body: `business_date`, `movement_type`, `amount_cents`, `reason`, optional `cash_session_id` | Attaches the movement to the caller's open shift (or `cash_session_id` when specified by `owner`/`manager`). | +| `POST` | `/movements` | `ROLE_ACCESS.ownerManagerCashier` | body: `business_date`, `movement_type`, `amount_cents`, `reason`, optional `cash_session_id` | Attaches the movement to the caller's open shift, or to `cash_session_id` when specified (`400` if not a positive integer, `404 cash_session_not_found` if not an open shift, `403 session_owner_required` if a non-`owner`/`manager` targets another user's shift). | | `POST` | `/movements/:id/void` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `reason` | - | -| `POST` | `/` | `ROLE_ACCESS.owner` | body: `business_date`, `opening_float_cents`, `counted_cash_cents`, `notes` | Closes a business date. `counted_cash_cents` is the physical count; the response carries the variance and the next `z_number`. `409` with `open_shift_blocks_day_close` until all shifts opened on or before that business date are closed (response includes `blocking_shift`). | +| `POST` | `/` | `ROLE_ACCESS.owner` | body: `business_date`, `opening_float_cents`, `counted_cash_cents`, `notes` | Closes a business date. `counted_cash_cents` is the physical count; the response carries the variance and the next `z_number`. `409` with `open_shift_blocks_day_close` until all shifts opened on or before that business date are closed (response includes `blocking_shift` and `blocking_shifts`). | | `POST` | `/:id/print` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `isReprint` | Prints the close receipt. | ### Customers @@ -554,7 +554,7 @@ Router: `main/routes/printers.ts`. Full path: `/api/printers`. | `DELETE` | `/:id` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `POST` | `/:id/set-default` | `ROLE_ACCESS.ownerManager` | path: `id` | - | | `POST` | `/:id/test` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `rasterProbe` | - | -| `POST` | `/kick-drawer` | `cash.movements.manage` | none | Requires an open cash shift; returns `409` with `{ error: "open_cash_shift_required" }` otherwise. Success is `{ success: true }` for USB/network printers or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no printer is configured, `400` if the selected connection is unsupported or lacks an IP address, and `502` if the pulse fails. | +| `POST` | `/kick-drawer` | `cash.movements.manage` | none | Requires an open cash shift for the caller (or any open shift for `owner`/`manager`); returns `409` with `{ error: "open_cash_shift_required" }` otherwise. Success is `{ success: true }` for USB/network printers or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no printer is configured, `400` if the selected connection is unsupported or lacks an IP address, and `502` if the pulse fails. | | `POST` | `/test-active` | `printing.execute` | none | Sends a standard test page to the default printer; returns `{ success: true }` or includes `webusb: true`, `bytes`, and `printerName` for browser delivery. `404` if no default printer is configured, `400` if the selected connection is unsupported or lacks an IP address, `409` if regional settings are missing, and `502` if printing fails. | | `POST` | `/print-menu` | `catalog.view` and `printing.execute` | body: boolean `includeInactive`, `includeOutOfStock`, `includeHidden`, `includeDescriptions`, `includeModifiers`; optional `paperWidth` (`58` or `80`) and `printerId` | `200` physical-print success, or `webusb: true` with byte array for browser delivery; `400` invalid input or `printer_not_configured`, `404` unknown `printerId` (`printer_not_found`), `409` missing regional configuration, `422` empty selection, `502` print failure. Prints to `printerId` when given, otherwise to the configured default, across connection types. | | `POST` | `/print-bill` | `ROLE_ACCESS.sales` | body: `billId`, `orderId`, `isReprint`, `preview`, `useUnicode`, `arabicShaping` | Body `billId` or `orderId`, plus `isReprint`, `preview`, `useUnicode`, `arabicShaping`. `preview` returns the rendered payload without sending it to the device. | @@ -862,7 +862,7 @@ day-boundary rules. | `GET` | `/recentOrders` | `ROLE_ACCESS.ownerManager` | query: `?limit`, `?date`, `?start_date`, `?end_date` | - | | `GET` | `/tables` | `ROLE_ACCESS.ownerManager` | none | Table-level utilisation and non-cancelled order totals for the current business date; no date parameter. | | `GET` | `/insights` | `ROLE_ACCESS.ownerManager` | query: `?days` | - | -| `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date`, optional `?session_id` | Live day or shift report. Recomputed on every read. When `?session_id` is omitted, aggregates the full business date across all shifts and includes `openShifts` and `blockingOpenShift`. When `?session_id` is provided, scopes the X report (`grossCollected`, `refunded`, `netCollected`, `paymentMethods`, `cashMovements`, `openingFloatCents`, `expectedCashCents`, and `session`) to that shift (`404` when not found). `openingFloatCents` is reported separately and excluded from `expectedCashCents`; the stored Z adds the opening float at close. | +| `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date`, optional `?session_id` | Live day or shift report. Recomputed on every read. When `?session_id` is omitted, aggregates the full business date across all shifts and includes `openShifts` and `blockingOpenShift` (`session: null`). When `?session_id` is provided, scopes the X report (`grossCollected`, `refunded`, `netCollected`, `paymentMethods`, `cashMovements`, `openingFloatCents`, `expectedCashCents`, and `session`) to that shift (`400 session_id_invalid` for a non-positive integer; `404 session_unavailable` when not found). `openingFloatCents` is reported separately and excluded from `expectedCashCents`; the stored Z adds the opening float at close. | | `GET` | `/z-report` | `ROLE_ACCESS.ownerManager` | query: `?date` | The immutable close snapshot for one business date. `404` with `{ error: "Day not closed", alreadyClosed: false, businessDate }` when no close row exists. The `zReport` object carries integer-minor-unit `*_cents` fields, `payment_methods`, `staff_sales`, `tax_components`, and `cash_movements` arrays, plus `z_number`, `closed_by`, `closed_by_name`, `notes`, `created_at`, `business_date`, `period_start`, and `period_end`. `variance_cents` is `counted_cash_cents - expected_cash_cents` and may be negative. | | `GET` | `/x-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv` or `xlsx`) | Exports a live X-report snapshot for one business date. Returns `400` with `{ error: "format must be xlsx or csv" }` when `format` is missing or invalid. Sets `Content-Disposition` to `attachment; filename="x-report-."`. | | `GET` | `/z-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv` or `xlsx`) | Exports the stored Z-report snapshot for a closed business date. Returns `400` with `{ error: "format must be xlsx or csv" }` when `format` is missing or invalid, and `404` with `{ error: "Day not closed" }` when the day has not been closed. Sets `Content-Disposition` to `attachment; filename="z-report-Z-."`. | diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index 4ea356777..1916c06eb 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -154,11 +154,15 @@ retains that user's exceptions; the editor shows their source (shipped default, user override) so they can be reviewed or cleared. **Staff > Permission change history** lists every save. -**Shift close.** Cashiers close only the shift they opened. An owner or manager can close any -session, and the closing actor is recorded on the closure row. The route is -`POST /api/cash-sessions/:id/close`; the own-session rule is enforced inside the close transaction, -where the stored session and the actor are read together. This is a context policy, not a -permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md). +**Cash shifts.** Opening, viewing, and closing shifts are gated by `cash.shifts.open`, +`cash.shifts.view`, and `cash.shifts.close`. Non-`owner`/`manager` roles (such as `cashier`) may +only view, preview, close, or attach explicit drawer movements to the shift they opened (`403 +session_owner_required` when targeting another user's shift), whereas an `owner` or `manager` can +inspect and close any staff member's open shift, with the closing actor recorded on the closure row. +On `POST /api/cash-sessions/:id/close`, the own-session rule is enforced inside the close +transaction, where the stored session and the actor are read together. This is a context policy, not +a permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md) +and [business time](../architecture/business-time.md#cash-closures). **Order cancellation and completion overrides.** Staff holding `orders.status.update` can cancel a whole order without a PIN only while the order and all of its items are `pending` and no bill holds @@ -206,15 +210,6 @@ matching grant is present, and it never opens a shift or grants a permission on behalf. The [Expenses API reference](api.md#expenses) lists the expense operations exposed over HTTP. -**Cash shifts.** Opening, viewing, and closing shifts are gated by `cash.shifts.open`, -`cash.shifts.view`, and `cash.shifts.close`. When the store setting `one_shift_per_staff` is -enabled, each staff member opens and closes their own shift and cash activity follows the -authenticated actor's open shift. A `cashier` (or any non-`owner`/`manager` role) may only view and -close their own shift (`403 session_owner_required` when targeting another user's shift), whereas an -`owner` or `manager` can inspect and close any staff member's open shift. Two concurrent shifts -sharing one physical drawer are allowed, but individual drawer counts are not physically isolated -when a single cash drawer is shared. - **Server App.** The standalone Server App is gated by `server-app.use`, shipped by default to `server`, `manager`, and `owner`. When the feature is disabled its routes return 404 rather than 403, so the surface is not discoverable. It is separate from the dashboard navigation. diff --git a/frontend/src/hooks/useCashSession.ts b/frontend/src/hooks/useCashSession.ts index 9f5eb5dd3..86c7ad45d 100644 --- a/frontend/src/hooks/useCashSession.ts +++ b/frontend/src/hooks/useCashSession.ts @@ -94,7 +94,7 @@ export function useCashSession() { const [closedResult, setClosedResult] = useState(null); const [printing, setPrinting] = useState(false); const [closePreview, setClosePreview] = useState(null); - const activeUserId = user?.id ?? null; + const activeUserId = user?.id != null ? String(user.id) : null; const [syncedUserId, setSyncedUserId] = useState(activeUserId); if (syncedUserId !== activeUserId) { setSyncedUserId(activeUserId); diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index 957f22ac2..ab6d0e4bb 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -945,8 +945,8 @@ router.post( movementType === 'opening_float', ); const reason = validateMovementReason(body.reason, movementType !== 'opening_float'); - const createdBy = String((req as any).user?.userId || ''); - const role = String((req as any).user?.role || ''); + const createdBy = String((req as AuthedRequest).user?.userId || ''); + const role = String((req as AuthedRequest).user?.role || ''); if (!createdBy) throw httpError('Authentication required', 401); const db = getDatabase(); let targetSessionId: number; diff --git a/main/routes/cash-sessions.ts b/main/routes/cash-sessions.ts index eb5a27a06..4b2f899ab 100644 --- a/main/routes/cash-sessions.ts +++ b/main/routes/cash-sessions.ts @@ -8,7 +8,8 @@ * Session expected cash prefers explicit cash_session_id ownership, with * timestamp windows only for legacy rows, so sessions crossing midnight work. * The opening float lives on the session - * row and is mirrored as the day's opening_float movement (once per day) + * row and is mirrored as the day's opening_float movement (once per day, or + * summed across per-staff shifts on that day) * so day close reconciles the same drawer; session math excludes in-window * float movements and cannot double-count. */ diff --git a/main/routes/printers.ts b/main/routes/printers.ts index 1b0bb4fae..573e5bccd 100644 --- a/main/routes/printers.ts +++ b/main/routes/printers.ts @@ -550,8 +550,9 @@ router.post( asyncHandler(async (req: Request, res: Response) => { try { const db = getDatabase(); - const actorUserId = String((req as any).user?.userId || ''); - const actorRole = String((req as any).user?.role || ''); + const actorUser = (req as Request & { user?: { userId?: string; role?: string } }).user; + const actorUserId = String(actorUser?.userId || ''); + const actorRole = String(actorUser?.role || ''); const hasOpenShift = Boolean(getOpenSession(db, actorUserId)) || ((actorRole === 'owner' || actorRole === 'manager') && listOpenSessions(db).length > 0); From b4757aebf9f2398085c9b4461841cc25a5031c89 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 13:24:04 -0400 Subject: [PATCH 16/22] no-mistakes(ci): ### Root Cause & Invariant - **Invariant:** When `one_shift_per_staff` is disabled (default), `getOpenSession` must preserve the store-wide open-session lookup (`SELECT * FROM cash_sessions WHERE status = 'open' LIMIT 1`), and when `one_shift_per_staff` is enabled, `getOpenSession` must look up only the specified actor's open session (`SELECT * FROM cash_sessions WHERE status = 'open' AND opened_by = ? LIMIT 1`) or return `undefined` when no actor user ID is provided. - **Root Cause:** [`getOpenSession`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M61KJ2A25N0GR48JQ5KGCY4B/main/services/shift-session-gate.ts#L48-L62) in [`main/services/shift-session-gate.ts`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M61KJ2A25N0GR48JQ5KGCY4B/main/services/shift-session-gate.ts) changed the store-wide open-session SQL query to `SELECT * FROM cash_sessions WHERE status = 'open' ORDER BY id ASC LIMIT 1` and kept an unrequired fallback to that query when `userId` was empty in per-staff mode. In [`tests/diagnostics-event.test.ts`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M61KJ2A25N0GR48JQ5KGCY4B/tests/diagnostics-event.test.ts#L1186-L1234) (section `4ab`), the test intercepts `SELECT * FROM cash_sessions WHERE status = 'open' LIMIT 1` to inject a synthetic database lookup failure during `POST /api/refunds`; because the SQL string no longer matched, the lookup returned `undefined` and triggered a `cash_session_required` validation rejection instead of preserving the database failure. ### Fix - Updated [`getOpenSession`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M61KJ2A25N0GR48JQ5KGCY4B/main/services/shift-session-gate.ts#L48-L62) in [`main/services/shift-session-gate.ts`](file:///Users/gurkiratkhaira/.no-mistakes/worktrees/de2296f2e6f8/01M61KJ2A25N0GR48JQ5KGCY4B/main/services/shift-session-gate.ts) to use `SELECT * FROM cash_sessions WHERE status = 'open' LIMIT 1` when `one_shift_per_staff` is off and removed the fallback query when `one_shift_per_staff` is on and `userId` is empty. ### Verification - `npm run test:diagnostics` (passed: all 7 suites including `tests/diagnostics-event.test.ts` with 471 passed, 0 failed) - `npm run lint` (passed) - `npm run build` (passed) - `npm run test:cash-sessions` (passed) - `npm run test:cash-closures` (passed) - `npm run test:upgrade-path` (passed) - `git diff --check` (passed) --- main/services/shift-session-gate.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/main/services/shift-session-gate.ts b/main/services/shift-session-gate.ts index 410de20aa..7278a1d39 100644 --- a/main/services/shift-session-gate.ts +++ b/main/services/shift-session-gate.ts @@ -50,19 +50,15 @@ export function getOpenSession( userId?: string | null, ): CashSessionRow | undefined { if (!isPerStaffShiftsEnabled(db)) { - return db - .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' ORDER BY id ASC LIMIT 1`) - .get() as CashSessionRow | undefined; + return db.prepare(`SELECT * FROM cash_sessions WHERE status = 'open' LIMIT 1`).get() as + | CashSessionRow + | undefined; } const normalizedUserId = typeof userId === 'string' ? userId.trim() : ''; - if (normalizedUserId) { - return db - .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' AND opened_by = ? LIMIT 1`) - .get(normalizedUserId) as CashSessionRow | undefined; - } + if (!normalizedUserId) return undefined; return db - .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' ORDER BY id ASC LIMIT 1`) - .get() as CashSessionRow | undefined; + .prepare(`SELECT * FROM cash_sessions WHERE status = 'open' AND opened_by = ? LIMIT 1`) + .get(normalizedUserId) as CashSessionRow | undefined; } const BUILT_IN_PAYMENT_METHODS = new Set(['cash', 'card', 'wallet']); From 0f750f40d0daf9406cbfe371f12e25fd1ff1fae9 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 13:42:28 -0400 Subject: [PATCH 17/22] fix(cash): harden per-staff shift X-report, movements, corrections, and PIN lockout --- main/middleware/security.ts | 33 ++- main/routes/auth.ts | 113 ++++++++--- main/routes/bills.ts | 326 ++++++++++++++++++++++-------- main/routes/cash-closures.ts | 237 ++++++++++++++++------ main/routes/cash-sessions.ts | 32 ++- main/routes/reports.ts | 296 +++++++++++++++++---------- tests/cash-sessions.test.ts | 357 ++++++++++++++++++++++----------- tests/pin-quick-switch.test.ts | 133 +++++++++--- 8 files changed, 1087 insertions(+), 440 deletions(-) diff --git a/main/middleware/security.ts b/main/middleware/security.ts index aa13ba4b2..3172aaf98 100644 --- a/main/middleware/security.ts +++ b/main/middleware/security.ts @@ -97,7 +97,9 @@ export function loginAuthRateLimit(options: { max?: number } = {}) { limit: Number.isFinite(parsedEnvMax) ? parsedEnvMax! : 10, standardHeaders: true, legacyHeaders: false, - message: { error: 'Too many authentication attempts. Please try again later.' }, + message: { + error: 'Too many authentication attempts. Please try again later.', + }, }); } return sharedLoginAuthRateLimiter; @@ -108,7 +110,9 @@ export function loginAuthRateLimit(options: { max?: number } = {}) { limit: options.max ?? (Number.isFinite(parsedEnvMax) ? parsedEnvMax : 10), standardHeaders: true, legacyHeaders: false, - message: { error: 'Too many authentication attempts. Please try again later.' }, + message: { + error: 'Too many authentication attempts. Please try again later.', + }, }); } @@ -134,7 +138,9 @@ export function authRateLimit( skipSuccessfulRequests: options.skipSuccessfulRequests ?? false, standardHeaders: true, legacyHeaders: false, - message: { error: 'Too many authentication attempts. Please try again later.' }, + message: { + error: 'Too many authentication attempts. Please try again later.', + }, ...(options.onRateLimited ? { handler: (req: Request, res: Response, _next: NextFunction, rateLimitOptions) => { @@ -155,7 +161,10 @@ const loginAttempts = new Map(); export const FAILED_LOGIN_MAX_ATTEMPTS = 5; export const FAILED_LOGIN_LOCKOUT_MINUTES = 15; -export function checkLoginRateLimit(ip: string): { allowed: boolean; waitMinutes?: number } { +export function checkLoginRateLimit(ip: string): { + allowed: boolean; + waitMinutes?: number; +} { const canonicalIp = normalizeIpForRateLimit(ip); const nowMs = Date.now(); let record = loginAttempts.get(canonicalIp); @@ -188,6 +197,10 @@ export function incrementFailedLogin(ip: string): number { export function resetSuccessfulLogin(ip: string): void { const canonicalIp = normalizeIpForRateLimit(ip); loginAttempts.delete(canonicalIp); + const pinIdx = canonicalIp.indexOf(':pin:'); + if (pinIdx >= 0) { + loginAttempts.delete(`pin-user:${canonicalIp.slice(pinIdx + 5)}`); + } } export function clearLoginRateLimits(): void { @@ -330,7 +343,11 @@ export function getUserAuthStatus( expiresAt: now + USER_AUTH_CACHE_TTL_MS, }; userAuthCache.set(userId, entry); - return { isActive: entry.isActive, role: entry.role, tokensValidAfter: entry.tokensValidAfter }; + return { + isActive: entry.isActive, + role: entry.role, + tokensValidAfter: entry.tokensValidAfter, + }; } /** Forces next requireAuth check for user to re-read DB instead of cache. */ @@ -391,13 +408,15 @@ export function revokeToken(token: string, verifiedExpiresAtMs?: number): void { const db = getDatabase(); const nowMs = Date.now(); cleanupExpiredRevocations(db, nowMs); - db.prepare(` + db.prepare( + ` INSERT INTO revoked_tokens (token_hash, expires_at, revoked_at) VALUES (?, ?, ?) ON CONFLICT(token_hash) DO UPDATE SET expires_at = excluded.expires_at, revoked_at = excluded.revoked_at - `).run(hashRevokedToken(token), expiresAt, now()); + `, + ).run(hashRevokedToken(token), expiresAt, now()); } catch (error) { // The in-memory fallback still blocks the token in this process. Normal // authenticated requests already fail when the database is unavailable. diff --git a/main/routes/auth.ts b/main/routes/auth.ts index 2bd853fe0..1f5370e26 100644 --- a/main/routes/auth.ts +++ b/main/routes/auth.ts @@ -139,7 +139,11 @@ function getUserCount(db: ReturnType): number { return (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; } -type PendingCurrencyReset = { country: string; currency: string; timezone: string }; +type PendingCurrencyReset = { + country: string; + currency: string; + timezone: string; +}; function getPendingCurrencyReset(db: ReturnType): PendingCurrencyReset | null { try { @@ -154,7 +158,11 @@ function getPendingCurrencyReset(db: ReturnType): PendingCur typeof parsed.timezone !== 'string' ) return null; - return { country: parsed.country, currency: parsed.currency, timezone: parsed.timezone }; + return { + country: parsed.country, + currency: parsed.currency, + timezone: parsed.timezone, + }; } catch { return null; } @@ -213,11 +221,17 @@ const passwordChangeAttempts = new Map nowMs) { - return { allowed: false, waitMinutes: Math.ceil((record.lockedUntil - nowMs) / 60000) }; + return { + allowed: false, + waitMinutes: Math.ceil((record.lockedUntil - nowMs) / 60000), + }; } if (record?.lockedUntil && record.lockedUntil <= nowMs) { passwordChangeAttempts.delete(userId); @@ -226,7 +240,10 @@ function checkPasswordChangeRateLimit(userId: string): { allowed: boolean; waitM } function incrementFailedPasswordChange(userId: string): number { - const record = passwordChangeAttempts.get(userId) || { count: 0, lockedUntil: 0 }; + const record = passwordChangeAttempts.get(userId) || { + count: 0, + lockedUntil: 0, + }; record.count += 1; if (record.count >= PASSWORD_CHANGE_MAX_ATTEMPTS) { record.lockedUntil = Date.now() + PASSWORD_CHANGE_LOCKOUT_MINUTES * 60000; @@ -288,7 +305,13 @@ router.post( const remember = !!rememberMe; const token = jwt.sign( - { userId: user.id, email: user.email, role: user.role, remember, jti: randomUUID() }, + { + userId: user.id, + email: user.email, + role: user.role, + remember, + jti: randomUUID(), + }, getJWTSecret(), { expiresIn: expiresInFor(remember) }, ); @@ -400,7 +423,8 @@ router.post( } const canonicalIp = normalizeIpForRateLimit(ip); - const rateLimitKey = `${canonicalIp}:pin:${userId}`; + const pinRateKey = `${canonicalIp}:pin:${userId}`; + const accountRateKey = `pin-user:${userId}`; type VerifiedUser = { id: string; name: string; @@ -416,27 +440,34 @@ router.post( | { status: 200; user: VerifiedUser }; const verificationResult = await withPinVerificationLock( - rateLimitKey, + accountRateKey, async () => { - const rateLimit = checkLoginRateLimit(rateLimitKey); - if (!rateLimit.allowed) { + const ipRateLimit = checkLoginRateLimit(pinRateKey); + const accountRateLimit = checkLoginRateLimit(accountRateKey); + if (!ipRateLimit.allowed || !accountRateLimit.allowed) { + const waitMinutes = Math.max( + ipRateLimit.allowed ? 0 : ipRateLimit.waitMinutes || 0, + accountRateLimit.allowed ? 0 : accountRateLimit.waitMinutes || 0, + ); recordAuthAudit(db, { userId, eventType: 'pin_login', outcome: 'rate_limited', ipAddress: ip, - details: { wait_minutes: rateLimit.waitMinutes }, + details: { wait_minutes: waitMinutes }, }); return { status: 429, body: { - error: `Too many failed attempts. Try again in ${rateLimit.waitMinutes} minutes.`, + error: `Too many failed attempts. Try again in ${waitMinutes} minutes.`, }, }; } if (!/^\d{4,6}$/.test(rawPin)) { - const attemptsRemaining = incrementFailedLogin(rateLimitKey); + const ipAttemptsRemaining = incrementFailedLogin(pinRateKey); + const accountAttemptsRemaining = incrementFailedLogin(accountRateKey); + const attemptsRemaining = Math.min(ipAttemptsRemaining, accountAttemptsRemaining); recordAuthAudit(db, { userId, eventType: 'pin_login', @@ -470,7 +501,9 @@ router.post( } if (!user || user.is_active !== 1 || !user.pin_hash || !pinMatches) { - const attemptsRemaining = incrementFailedLogin(rateLimitKey); + const ipAttemptsRemaining = incrementFailedLogin(pinRateKey); + const accountAttemptsRemaining = incrementFailedLogin(accountRateKey); + const attemptsRemaining = Math.min(ipAttemptsRemaining, accountAttemptsRemaining); recordAuthAudit(db, { userId, eventType: 'pin_login', @@ -497,7 +530,8 @@ router.post( }; } - resetSuccessfulLogin(rateLimitKey); + resetSuccessfulLogin(pinRateKey); + resetSuccessfulLogin(accountRateKey); recordAuthAudit(db, { userId: user.id, eventType: 'pin_login', @@ -516,7 +550,13 @@ router.post( const { user } = verificationResult; const remember = !!rememberMe; const token = jwt.sign( - { userId: user.id, email: user.email, role: user.role, remember, jti: randomUUID() }, + { + userId: user.id, + email: user.email, + role: user.role, + remember, + jti: randomUUID(), + }, getJWTSecret(), { expiresIn: expiresInFor(remember) }, ); @@ -694,7 +734,12 @@ router.get('/me', (req: Request, res: Response) => { const tenant = buildLocalTenant(db, user.id, user.role); res.json({ - user: { id: user.id, name: user.name, email: user.email, role: user.role }, + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, tenants: [tenant], }); } catch { @@ -791,10 +836,12 @@ function applyOwnerRecovery( restoredOwnerAccess = true; return db - .prepare(` + .prepare( + ` UPDATE users SET password = ?, role = ?, tokens_valid_after = ?, updated_at = ? WHERE id = ? AND is_active = 1 - `) + `, + ) .run(hashedPassword, INITIAL_ADMIN_ROLE, changedAt, changedAt, user.id); })(); if (updated.changes === 0) { @@ -978,7 +1025,9 @@ router.get( asyncHandler(async (req: Request, res: Response) => { if (!cloudRecoveryPrecheck(req, res)) return; try { - res.json({ verified: await cloudSync.isOwnerEmailVerified(getHttpRequestSignal(req)) }); + res.json({ + verified: await cloudSync.isOwnerEmailVerified(getHttpRequestSignal(req)), + }); } catch (error) { sendCloudRecoveryFailure(res, error); } @@ -1026,9 +1075,9 @@ router.post( .prepare('SELECT * FROM users WHERE lower(email) = ? AND role = ? AND is_active = 1') .get(email, INITIAL_ADMIN_ROLE) as any; if (!user) { - return res - .status(404) - .json({ error: 'No active owner account matches the registered email.' }); + return res.status(404).json({ + error: 'No active owner account matches the registered email.', + }); } try { @@ -1197,9 +1246,9 @@ router.post('/setup/initialize', (req: Request, res: Response) => { const ownerApprovalPin = String(owner_approval_pin || ''); if (!/^\d{4,6}$/.test(ownerApprovalPin)) { - return res - .status(400) - .json({ error: 'A 4-6 digit owner Approval PIN is required to complete setup' }); + return res.status(400).json({ + error: 'A 4-6 digit owner Approval PIN is required to complete setup', + }); } if (ownerApprovalPin !== String(owner_approval_pin_confirmation || '')) { return res.status(400).json({ error: 'Owner Approval PINs do not match' }); @@ -1251,10 +1300,12 @@ router.post('/setup/initialize', (req: Request, res: Response) => { } userId = randomUUID(); - db.prepare(` + db.prepare( + ` INSERT INTO users (id, name, email, password, role, pin_hash, is_active, terms_accepted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( + `, + ).run( userId, displayName, email, @@ -1377,9 +1428,9 @@ router.post('/setup/initialize', (req: Request, res: Response) => { // Legacy endpoint retained to direct callers to /api/auth/setup/initialize. router.post('/setup/seed', (req: Request, res: Response) => { - res - .status(410) - .json({ error: 'Use /api/auth/setup/initialize with setup_profile and owner details.' }); + res.status(410).json({ + error: 'Use /api/auth/setup/initialize with setup_profile and owner details.', + }); }); export const authRoutes = router; diff --git a/main/routes/bills.ts b/main/routes/bills.ts index cd590027e..8f1b391d5 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -29,6 +29,7 @@ import { isExcludedFromBillReceipt } from '../../shared/order-item-status'; import { getOpenSession, isCashTender, + isPerStaffShiftsEnabled, NO_CASH_SESSION_ID, requireOpenSessionForCashTender, } from '../services/shift-session-gate'; @@ -204,7 +205,12 @@ export async function preVerifyKitchenDeliveryPin( "SELECT id, role, pin_hash, is_active FROM users WHERE id = ? AND role IN ('owner', 'manager') AND is_active = 1 AND pin_hash IS NOT NULL", ) .get(explicitApproverId) as - | { id: string; role: string; pin_hash: string | null; is_active: number } + | { + id: string; + role: string; + pin_hash: string | null; + is_active: number; + } | undefined; if ( !candidate || @@ -214,7 +220,9 @@ export async function preVerifyKitchenDeliveryPin( !(await verifyPinAsync(candidate.pin_hash, overridePin)) ) { checkPinRateLimit(rateLimitKey); - throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); + throw Object.assign(new Error('Invalid manager PIN'), { + statusCode: 403, + }); } clearPinRateLimit(rateLimitKey); return { id: candidate.id, verifiedPinHash: candidate.pin_hash }; @@ -276,18 +284,22 @@ export function checkKitchenDeliveryStatus( db.prepare('SELECT 1 FROM bill_items WHERE bill_id = ? LIMIT 1').get(billId); const undeliveredItems = hasBillItems ? (db - .prepare(` + .prepare( + ` SELECT oi.product_name FROM bill_items bi JOIN order_items oi ON oi.id = bi.order_item_id WHERE bi.bill_id = ? AND bi.quantity > 0 AND oi.order_id = ? AND oi.status IN ('pending', 'preparing', 'ready') - `) + `, + ) .all(billId, orderId) as { product_name: string }[]) : (db - .prepare(` + .prepare( + ` SELECT product_name FROM order_items WHERE order_id = ? AND status IN ('pending', 'preparing', 'ready') - `) + `, + ) .all(orderId) as { product_name: string }[]); if (undeliveredItems.length === 0) return { allowed: true, count: 0, items: [] }; @@ -320,7 +332,12 @@ export function checkKitchenDeliveryStatus( "SELECT id, role, pin_hash, is_active FROM users WHERE id = ? AND role IN ('owner', 'manager') AND is_active = 1 AND pin_hash IS NOT NULL", ) .get(preVerifiedManager.id) as - | { id: string; role: string; pin_hash: string | null; is_active: number } + | { + id: string; + role: string; + pin_hash: string | null; + is_active: number; + } | undefined; if ( candidate && @@ -364,7 +381,13 @@ export function checkKitchenDeliveryStatus( throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); } clearPinRateLimit(rateLimitKey); - return { allowed: true, count: items.length, items, overridden: true, managerUserId: manager.id }; + return { + allowed: true, + count: items.length, + items, + overridden: true, + managerUserId: manager.id, + }; } export function getTenantCurrency(): string { @@ -1124,7 +1147,8 @@ router.post('/generate', requirePermission('bills.generate'), (req: Request, res const paidAmount = Number(existingBill.paid_amount || 0); if (totalsChanged) { const newBalance = Math.max(0, roundedOrderTotal - paidAmount); - db.prepare(` + db.prepare( + ` UPDATE bills SET subtotal = ?, tax_amount = ?, @@ -1143,7 +1167,8 @@ router.post('/generate', requirePermission('bills.generate'), (req: Request, res balance = ?, updated_at = ? WHERE id = ? - `).run( + `, + ).run( orderSubtotal, orderTaxAmount, order.tax_breakdown, @@ -1216,12 +1241,14 @@ router.post('/generate', requirePermission('bills.generate'), (req: Request, res ); const runResult = db - .prepare(` + .prepare( + ` INSERT INTO bills (bill_number, order_id, customer_id, subtotal, tax_amount, tax_breakdown, tax_snapshot, discount_amount, discount_type, discount_value, discount_reason, delivery_charge, packaging_charge, service_charge, charges_breakdown, round_off, total, paid_amount, balance, payment_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unpaid', ?, ?) - `) + `, + ) .run( billNumber, order_id, @@ -1657,7 +1684,11 @@ function allocateTaxSnapshotsWithTax( return JSON.stringify(childResult); }); - return { snapshots: childSnapshots, taxMinors: snapshotTaxMinors, exclusiveTaxMinors }; + return { + snapshots: childSnapshots, + taxMinors: snapshotTaxMinors, + exclusiveTaxMinors, + }; } export function allocateTaxSnapshots( @@ -1814,7 +1845,11 @@ function allocateTaxBreakdown( const allocationGroups = new Map< string, - { entries: Array<{ allocations: number[] }>; weights: number[]; sourceMinor: number } + { + entries: Array<{ allocations: number[] }>; + weights: number[]; + sourceMinor: number; + } >(); const flatOwnerAllocations = new Array(numChecks).fill(0); components.forEach((comp, index) => { @@ -1997,7 +2032,11 @@ function allocateLegacyTaxContribution( snapshotAllocation: TaxSnapshotAllocationResult, contribution: LegacyTaxContribution, fallbackWeights: number[], -): { legacySourceTaxMinor: number; legacyTaxMinors: number[]; legacyExclusiveTaxMinors: number[] } { +): { + legacySourceTaxMinor: number; + legacyTaxMinors: number[]; + legacyExclusiveTaxMinors: number[]; +} { const snapshotSourceTaxMinor = snapshotAllocation.taxMinors === null ? 0 @@ -2188,7 +2227,10 @@ function resolveSplitTaxAllocations( return { taxMinors: fallbackTaxMinors, exclusiveTaxMinors: null }; } if (legacySourceTaxMinor === sourceTaxMinor) { - return { taxMinors: legacyTaxMinors, exclusiveTaxMinors: legacyExclusiveTaxMinors }; + return { + taxMinors: legacyTaxMinors, + exclusiveTaxMinors: legacyExclusiveTaxMinors, + }; } return { taxMinors: fallbackTaxMinors, exclusiveTaxMinors: null }; } @@ -2505,7 +2547,9 @@ router.post( .prepare('SELECT * FROM orders WHERE id = ?') .get(txnSource.order_id) as any; if (txnOrder?.type !== 'dine_in') - throw Object.assign(new Error('Only dine-in checks can be split'), { statusCode: 400 }); + throw Object.assign(new Error('Only dine-in checks can be split'), { + statusCode: 400, + }); if ( txnSource.payment_status !== 'unpaid' || Number(txnSource.paid_amount || 0) !== 0 || @@ -2530,7 +2574,9 @@ router.post( ).n, ) > 1 ) { - throw Object.assign(new Error('This check has already been split'), { statusCode: 409 }); + throw Object.assign(new Error('This check has already been split'), { + statusCode: 409, + }); } const tenantCurrency = getTenantCurrency(); @@ -2558,7 +2604,10 @@ router.post( .prepare( 'SELECT order_item_id, quantity FROM bill_items WHERE bill_id = ? ORDER BY order_item_id', ) - .all(txnSource.id) as { order_item_id: number | string; quantity: number | string }[]; + .all(txnSource.id) as { + order_item_id: number | string; + quantity: number | string; + }[]; const wellFormed = sourceAllocations.length > 0 && sourceAllocations.every( @@ -2821,13 +2870,15 @@ router.post( billId = Number(txnSource.id); } else { const inserted = db - .prepare(` + .prepare( + ` INSERT INTO bills (bill_number, order_id, customer_id, subtotal, tax_amount, tax_breakdown, tax_snapshot, discount_amount, discount_type, discount_value, discount_reason, delivery_charge, packaging_charge, service_charge, charges_breakdown, round_off, total, paid_amount, balance, payment_status, split_group_id, split_label, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 'unpaid', ?, ?, ?, ?) - `) + `, + ) .run( generateBillNumber(), txnSource.order_id, @@ -2920,7 +2971,9 @@ function paymentIdempotencyKey(req: Request): string | null { const supplied = req.get('Idempotency-Key')?.trim(); if (!supplied) return null; if (supplied.length > MAX_IDEMPOTENCY_KEY_LENGTH || !/^[\x21-\x7e]+$/.test(supplied)) { - throw Object.assign(new Error('Idempotency-Key is invalid or too long'), { statusCode: 400 }); + throw Object.assign(new Error('Idempotency-Key is invalid or too long'), { + statusCode: 400, + }); } return supplied; } @@ -3129,7 +3182,11 @@ function preparePaymentBatch( `Unsupported or inactive custom payment method at line ${index + 1}`, 'payment_method_unavailable', ); - return { ...payment, method: configured.name, payment_method_id: Number(configured.id) }; + return { + ...payment, + method: configured.name, + payment_method_id: Number(configured.id), + }; }); const requestedCustomerId = bodyCustomerId === undefined || bodyCustomerId === null || bodyCustomerId === '' @@ -3225,7 +3282,13 @@ function preparePaymentBatch( ), ); if (replay) { - return { bill, prepared: [], existingPayments, effectiveCustomerId, idempotentReplay: true }; + return { + bill, + prepared: [], + existingPayments, + effectiveCustomerId, + idempotentReplay: true, + }; } if (getSettingValue('card_transaction_id_required') === 'true') { resolvedPayments.forEach((payment, index) => { @@ -3596,13 +3659,15 @@ function applyPaymentBatch( if (idempotencyKey && idempotencyUserId) { // Look up idempotency record scoped to user or legacy fallback. const prior = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as | { bill_id: string; request_hash: string; response_json: string } | undefined; @@ -3617,7 +3682,9 @@ function applyPaymentBatch( try { return JSON.parse(prior.response_json); } catch { - throw Object.assign(new Error('Stored payment response is invalid'), { statusCode: 500 }); + throw Object.assign(new Error('Stored payment response is invalid'), { + statusCode: 500, + }); } } } @@ -3975,13 +4042,15 @@ router.post( if (idempotencyKey && idempotencyUserId) { const inFlightAttemptKey = `${idempotencyUserId}:${idempotencyKey}`; const prior = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as | { bill_id: string; request_hash: string; response_json: string } | undefined; @@ -4020,15 +4089,21 @@ router.post( return res.json(sharedResult); } catch { const committed = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as - | { bill_id: string; request_hash: string; response_json: string } + | { + bill_id: string; + request_hash: string; + response_json: string; + } | undefined; if (committed) { if ( @@ -4092,13 +4167,15 @@ router.post( } catch (pinErr: unknown) { if (idempotencyKey && idempotencyUserId) { const committed = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as | { bill_id: string; request_hash: string; response_json: string } | undefined; @@ -4236,13 +4313,15 @@ router.post( if (idempotencyKey && idempotencyUserId) { const inFlightAttemptKey = `${idempotencyUserId}:${idempotencyKey}`; const prior = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as | { bill_id: string; request_hash: string; response_json: string } | undefined; @@ -4281,15 +4360,21 @@ router.post( return res.json(sharedResult); } catch { const committed = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as - | { bill_id: string; request_hash: string; response_json: string } + | { + bill_id: string; + request_hash: string; + response_json: string; + } | undefined; if (committed) { if ( @@ -4353,13 +4438,15 @@ router.post( } catch (pinErr: unknown) { if (idempotencyKey && idempotencyUserId) { const committed = db - .prepare(` + .prepare( + ` SELECT bill_id, request_hash, response_json FROM payment_idempotency WHERE (user_id = ? OR user_id = 'legacy') AND idempotency_key = ? ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END LIMIT 1 - `) + `, + ) .get(idempotencyUserId, idempotencyKey, idempotencyUserId) as | { bill_id: string; request_hash: string; response_json: string } | undefined; @@ -4560,9 +4647,10 @@ router.post( if (requiresApproval && value > 0) { const { override_pin } = req.body; if (!override_pin) { - return res - .status(403) - .json({ error: 'Manager PIN required for discounts', requiresApproval: true }); + return res.status(403).json({ + error: 'Manager PIN required for discounts', + requiresApproval: true, + }); } const canonicalIp = normalizeIpForRateLimit(clientIp); const rateLimitKey = `pin:${canonicalIp}:bill-discount`; @@ -4574,7 +4662,11 @@ router.post( return res.status(403).json({ error: 'Invalid manager PIN' }); } const managerId = explicitApproverId; - type ManagerCandidate = { id: string; role: string; pin_hash: string | null }; + type ManagerCandidate = { + id: string; + role: string; + pin_hash: string | null; + }; const verifiedResult = await withPinVerificationLock(rateLimitKey, async () => { if (isPinRateLimited(rateLimitKey)) { return { rateLimited: true }; @@ -4631,9 +4723,15 @@ router.post( preVerifiedPinHash = verifiedResult.user.pin_hash ?? undefined; } - let preVerifiedKitchenManager: { id: string; verifiedPinHash: string } | null = null; + let preVerifiedKitchenManager: { + id: string; + verifiedPinHash: string; + } | null = null; if (approvedByUserId && preVerifiedPinHash) { - preVerifiedKitchenManager = { id: approvedByUserId, verifiedPinHash: preVerifiedPinHash }; + preVerifiedKitchenManager = { + id: approvedByUserId, + verifiedPinHash: preVerifiedPinHash, + }; } else if (override_pin) { const initialKitchenStatus = checkKitchenDeliveryStatus( db, @@ -4740,7 +4838,12 @@ router.post( "SELECT id, role, pin_hash, is_active FROM users WHERE id = ? AND role IN ('owner', 'manager')", ) .get(approvedByUserId) as - | { id: string; role: string; pin_hash: string | null; is_active: number } + | { + id: string; + role: string; + pin_hash: string | null; + is_active: number; + } | undefined; if ( !approver || @@ -4749,7 +4852,9 @@ router.post( !hasPermission(approver.id, 'bills.discount.apply') || approver.pin_hash !== preVerifiedPinHash ) { - throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); + throw Object.assign(new Error('Invalid manager PIN'), { + statusCode: 403, + }); } } @@ -4785,7 +4890,9 @@ router.post( .prepare('SELECT * FROM orders WHERE id = ?') .get(currentBill.order_id) as ChargeOrderRow | undefined; if (!currentOrder) { - throw Object.assign(new Error('Order not found'), { statusCode: 404 }); + throw Object.assign(new Error('Order not found'), { + statusCode: 404, + }); } if (['completed', 'cancelled'].includes(currentOrder.status)) { throw Object.assign( @@ -4869,7 +4976,8 @@ router.post( ); const isNowPaid = freshPaidAmount > 0 && freshNewBalance === 0 && kitchenStatus.allowed; - db.prepare(` + db.prepare( + ` UPDATE bills SET subtotal = ?, discount_amount = ?, discount_type = ?, discount_value = ?, discount_reason = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, service_charge = ?, packaging_charge = ?, charges_breakdown = ?, @@ -4878,7 +4986,8 @@ router.post( paid_at = CASE WHEN ? = 1 THEN COALESCE(paid_at, ?) ELSE paid_at END, updated_at = ? WHERE id = ? - `).run( + `, + ).run( freshTotals.subtotal, freshDiscountAmount, type, @@ -4902,13 +5011,15 @@ router.post( // orders.total stays the exact, unrounded amount — only the bill (the // settlement boundary) holds the pack-rounded payable total (#170). - db.prepare(` + db.prepare( + ` UPDATE orders SET subtotal = ?, discount_amount = ?, discount_type = ?, discount_value = ?, discount_reason = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, service_charge = ?, packaging_charge = ?, charges_breakdown = ?, total = ?, round_off = ?, updated_at = ? WHERE id = ? - `).run( + `, + ).run( freshTotals.subtotal, freshDiscountAmount, type, @@ -5218,9 +5329,10 @@ router.patch( let preVerifiedChargePinHash: string | undefined; if (requiresChargeApproval) { if (!override_pin) { - return res - .status(403) - .json({ error: 'Manager PIN required to waive charges', requiresApproval: true }); + return res.status(403).json({ + error: 'Manager PIN required to waive charges', + requiresApproval: true, + }); } const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; const canonicalIp = normalizeIpForRateLimit(clientIp); @@ -5232,7 +5344,11 @@ router.patch( checkPinRateLimit(rateLimitKey); return res.status(403).json({ error: 'Invalid manager PIN' }); } - type ManagerCandidate = { id: string; role: string; pin_hash: string | null }; + type ManagerCandidate = { + id: string; + role: string; + pin_hash: string | null; + }; const verifiedResult = await withPinVerificationLock(rateLimitKey, async () => { if (isPinRateLimited(rateLimitKey)) { return { rateLimited: true }; @@ -5296,7 +5412,12 @@ router.patch( "SELECT id, role, pin_hash, is_active FROM users WHERE id = ? AND role IN ('owner', 'manager')", ) .get(approvedByUserId) as - | { id: string; role: string; pin_hash: string | null; is_active: number } + | { + id: string; + role: string; + pin_hash: string | null; + is_active: number; + } | undefined; if ( !approver || @@ -5305,7 +5426,9 @@ router.patch( !hasPermission(approver.id, 'bills.discount.apply') || approver.pin_hash !== preVerifiedChargePinHash ) { - throw Object.assign(new Error('Invalid manager PIN'), { statusCode: 403 }); + throw Object.assign(new Error('Invalid manager PIN'), { + statusCode: 403, + }); } } @@ -5421,10 +5544,12 @@ router.patch( // The order owns the authoritative breakdown; a bill that never carried one // inherits it so the two cannot disagree. - db.prepare(` + db.prepare( + ` UPDATE orders SET subtotal = ?, tax_amount = ?, tax_breakdown = ?, tax_snapshot = ?, discount_amount = ?, service_charge = ?, packaging_charge = ?, charges_breakdown = ?, total = ?, updated_at = ? WHERE id = ? - `).run( + `, + ).run( totals.subtotal, taxRollup.taxAmount, JSON.stringify(taxRollup.breakdowns), @@ -5556,9 +5681,9 @@ router.post( // Return 404 for "Bill not found", 500 for other errors const statusCode = error.message?.includes('Bill not found') ? 404 : 500; console.error('[API] Receipt printing failed:', error); - res - .status(statusCode) - .json({ error: statusCode >= 500 ? 'Receipt printing failed' : 'Bill not found' }); + res.status(statusCode).json({ + error: statusCode >= 500 ? 'Receipt printing failed' : 'Bill not found', + }); } }), ); @@ -5568,13 +5693,15 @@ router.get('/:id/print-history', requirePermission('bills.read'), (req: Request, try { const db = getDatabase(); const prints = db - .prepare(` + .prepare( + ` SELECT pl.*, u.name as user_name FROM print_logs pl LEFT JOIN users u ON pl.user_id = u.id WHERE pl.bill_id = ? ORDER BY pl.printed_at DESC - `) + `, + ) .all(req.params.id); res.json({ prints }); @@ -5695,7 +5822,12 @@ router.patch( const callerUser = (req as Request & { user?: { userId?: string; id?: string } }).user; const rawActorUserId = String(callerUser?.userId || callerUser?.id || ''); - type ApproverRow = { id: string; role: string; pin_hash: string | null; is_active: number }; + type ApproverRow = { + id: string; + role: string; + pin_hash: string | null; + is_active: number; + }; const preVerifiedResult = await withPinVerificationLock(rateLimitKey, async () => { if (isPinRateLimited(rateLimitKey)) { return { rateLimited: true as const }; @@ -5722,7 +5854,10 @@ router.patch( ) { clearPinRateLimit(rateLimitKey); return { - approver: { id: candidate.id, verifiedPinHash: candidate.pin_hash }, + approver: { + id: candidate.id, + verifiedPinHash: candidate.pin_hash, + }, }; } } @@ -5927,11 +6062,17 @@ router.patch( } let effectiveSessionId: number; - const lineSessionId = - targetLine.cash_session_id !== undefined && targetLine.cash_session_id !== null - ? Number(targetLine.cash_session_id) - : 0; - if (Number.isInteger(lineSessionId) && lineSessionId > 0) { + const hasLineSessionId = + targetLine.cash_session_id !== undefined && targetLine.cash_session_id !== null; + const lineSessionId = hasLineSessionId ? Number(targetLine.cash_session_id) : null; + if (hasLineSessionId) { + if (!Number.isInteger(lineSessionId) || lineSessionId === null || lineSessionId <= 0) { + throw paymentValidationError( + 'Cannot correct payment method: no open cash shift for this payment', + 'shift_closed', + 409, + ); + } const session = db .prepare('SELECT id, status FROM cash_sessions WHERE id = ?') .get(lineSessionId) as { id: number; status: string } | undefined; @@ -5944,7 +6085,14 @@ router.patch( } effectiveSessionId = session.id; } else { - const openSession = getOpenSession(db, effectiveActorUserId); + if (isPerStaffShiftsEnabled(db)) { + throw paymentValidationError( + 'Cannot correct payment method: the shift for this payment is closed', + 'shift_closed', + 409, + ); + } + const openSession = getOpenSession(db); if (!openSession) { throw paymentValidationError( 'Cannot correct payment method: no open cash shift', @@ -6001,11 +6149,17 @@ router.patch( 409, ); } - const sibSessionId = - sibLine.cash_session_id !== undefined && sibLine.cash_session_id !== null - ? Number(sibLine.cash_session_id) - : 0; - if (Number.isInteger(sibSessionId) && sibSessionId > 0) { + const hasSibSessionId = + sibLine.cash_session_id !== undefined && sibLine.cash_session_id !== null; + const sibSessionId = hasSibSessionId ? Number(sibLine.cash_session_id) : null; + if (hasSibSessionId) { + if (!Number.isInteger(sibSessionId) || sibSessionId === null || sibSessionId <= 0) { + throw paymentValidationError( + 'Cannot correct payment method: a split sibling check was settled outside an open shift', + 'split_sibling_shift_closed', + 409, + ); + } const sibSession = db .prepare('SELECT id, status FROM cash_sessions WHERE id = ?') .get(sibSessionId) as { id: number; status: string } | undefined; @@ -6021,12 +6175,20 @@ router.patch( ); } } else { - const openSession = getOpenSession(db, effectiveActorUserId); + if (isPerStaffShiftsEnabled(db)) { + throw paymentValidationError( + 'Cannot correct payment method: a split sibling check was settled in a closed shift', + 'split_sibling_shift_closed', + 409, + ); + } + const openSession = getOpenSession(db); const sibTime = String( sibLine.timestamp || sibling.paid_at || sibling.created_at || '', ); if ( !openSession || + openSession.id !== effectiveSessionId || (sibTime && openSession.opened_at && sibTime < String(openSession.opened_at)) ) { throw paymentValidationError( @@ -6294,7 +6456,7 @@ router.patch( const updatedLine: Record = { ...targetLine, method: resolvedMethod, - cash_session_id: effectiveSessionId, + ...(lineSessionId && lineSessionId > 0 ? { cash_session_id: effectiveSessionId } : {}), }; if (resolvedPaymentMethodId !== undefined) { updatedLine.payment_method_id = resolvedPaymentMethodId; diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index ab6d0e4bb..fa3e7740f 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -216,14 +216,16 @@ function listCashDrawerMovements( includeVoided = true, ): CashDrawerMovementRow[] { return db - .prepare(` + .prepare( + ` SELECT m.*, created_user.name AS created_by_name, voided_user.name AS voided_by_name FROM cash_drawer_movements m LEFT JOIN users created_user ON created_user.id = m.created_by LEFT JOIN users voided_user ON voided_user.id = m.voided_by WHERE m.business_date = ? ${includeVoided ? '' : 'AND m.voided_at IS NULL'} ORDER BY m.created_at DESC, m.id DESC - `) + `, + ) .all(businessDate) as CashDrawerMovementRow[]; } @@ -237,7 +239,8 @@ export function listCashDrawerMovementsForSession( includeVoided = true, ): CashDrawerMovementRow[] { return db - .prepare(` + .prepare( + ` SELECT m.*, created_user.name AS created_by_name, voided_user.name AS voided_by_name FROM cash_drawer_movements m LEFT JOIN users created_user ON created_user.id = m.created_by @@ -245,7 +248,8 @@ export function listCashDrawerMovementsForSession( WHERE (m.cash_session_id = ? OR (m.cash_session_id IS NULL AND m.created_at >= ? AND m.created_at < ?)) ${includeVoided ? '' : 'AND m.voided_at IS NULL'} ORDER BY m.created_at DESC, m.id DESC - `) + `, + ) .all(sessionId, start, end) as CashDrawerMovementRow[]; } @@ -254,12 +258,14 @@ function activeOpeningFloatCents( businessDate: string, ): number | null { const row = db - .prepare(` + .prepare( + ` SELECT amount_cents FROM cash_drawer_movements WHERE business_date = ? AND movement_type = 'opening_float' AND voided_at IS NULL LIMIT 1 - `) + `, + ) .get(businessDate) as { amount_cents: number } | undefined; return row ? Number(row.amount_cents) : null; } @@ -395,7 +401,8 @@ export function paymentMethodBreakdown( ]; const minorFactor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); return db - .prepare(` + .prepare( + ` WITH payment_lines AS ( SELECT b.paid_at, b.created_at, je.value AS line FROM bills b @@ -438,7 +445,8 @@ export function paymentMethodBreakdown( WHERE payment_time >= datetime(?) AND payment_time < datetime(?) GROUP BY COALESCE(pm.name, normalized.method) ORDER BY total DESC - `) + `, + ) .all( end, start, @@ -522,7 +530,8 @@ export function cashDrawerSalesAndRefunds( minorFactor: number, ): { salesCents: number; refundsCents: number } { const row = db - .prepare(` + .prepare( + ` WITH cash_named_custom_methods AS ( SELECT id FROM payment_methods WHERE lower(trim(name)) = 'cash' ), @@ -565,7 +574,8 @@ export function cashDrawerSalesAndRefunds( SELECT (SELECT sales_cents FROM cash_sales) AS sales_cents, (SELECT refunds_cents FROM cash_refunds) AS refunds_cents - `) + `, + ) .get(end, start, minorFactor, start, end, start, end) as { sales_cents: number; refunds_cents: number; @@ -632,7 +642,8 @@ export function sessionExpectedCash( true, ); const eventTotals = db - .prepare(` + .prepare( + ` WITH payment_lines AS ( SELECT CAST(json_extract(je.value, '$.amount') AS REAL) AS amount, @@ -678,7 +689,8 @@ export function sessionExpectedCash( SELECT (SELECT sales_cents FROM cash_sales) AS sales_cents, (SELECT refunds_cents FROM cash_refunds) AS refunds_cents - `) + `, + ) .get(minorFactor, session.id, start, end, session.id, start, end) as { sales_cents: number; refunds_cents: number; @@ -709,7 +721,8 @@ export function sessionFinancialTotals( > { const minorFactor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); const row = db - .prepare(` + .prepare( + ` WITH payment_lines AS ( SELECT b.id AS bill_id, CAST(ROUND(CAST(json_extract(je.value, '$.amount') AS REAL) * ?) AS INTEGER) AS amount_cents, @@ -750,7 +763,8 @@ export function sessionFinancialTotals( (SELECT COALESCE(json_group_array(json_object( 'method', method, 'count', count, 'total_cents', total_cents)), '[]') FROM method_totals) AS methods_json - `) + `, + ) .get(minorFactor, session.id, session.opened_at, end, session.id, session.opened_at, end) as { bill_count: number; refund_count: number; @@ -782,18 +796,21 @@ export function computePeriodAggregates( end: string, movements: CashDrawerMovementRow[], openingFloatOverride: number | null, + session?: { id: number; opened_at: string }, ): DayAggregates { // Display gross — `SUM(paid_amount)` over the paid_at day window (NOT // SUM(total) over created_at). This matches financial-summary so display // totals reconcile with the existing report endpoint for the same day. const billRow = db - .prepare(` + .prepare( + ` SELECT COUNT(*) AS bill_count, COALESCE(SUM(b.paid_amount), 0) AS gross_collected FROM bills b WHERE b.paid_at >= ? AND b.paid_at < ? - `) + `, + ) .get(start, end) as { bill_count: number; gross_collected: number }; // Display refunds — paid_at attribution, same as financial-summary. @@ -802,14 +819,16 @@ export function computePeriodAggregates( // / response-edge conversion happens only at the { zReport } boundary, never // in storage or in the storage-time net subtraction. const refundRow = db - .prepare(` + .prepare( + ` SELECT COUNT(*) AS refund_count, COALESCE(SUM(r.amount_cents), 0) AS refunded_cents FROM refunds r JOIN bills b ON b.id = r.bill_id WHERE b.paid_at >= ? AND b.paid_at < ? - `) + `, + ) .get(start, end) as { refund_count: number; refunded_cents: number }; // cash sales (drawer-reality side of the split): raw pre-join `method='cash'` @@ -844,8 +863,46 @@ export function computePeriodAggregates( // creation-time key, which produced a non-reconciling Z with respect to // the rest of the snapshot). Unpaid orders drop out: uncollected money // is not staff revenue for the day it was created. - const staffSalesRows = db - .prepare(` + const staffSalesRows = session + ? (db + .prepare( + ` + WITH payment_lines AS ( + SELECT b.id AS bill_id, + CAST(json_extract(je.value, '$.amount') AS REAL) AS amount, + CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) AS line_session, + COALESCE(datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), datetime(NULLIF(b.created_at, ''))) AS line_time + FROM bills b + JOIN json_each(CASE + WHEN json_valid(b.payment_details) AND json_type(b.payment_details) = 'array' THEN b.payment_details + WHEN json_valid(b.payment_details) THEN json_array(b.payment_details) + ELSE '[]' END) je + WHERE b.paid_at IS NOT NULL AND json_type(je.value) = 'object' + ), + owned_bill_totals AS ( + SELECT bill_id, SUM(amount) AS session_paid_amount + FROM payment_lines + WHERE line_session = ? + OR (line_session IS NULL AND line_time >= datetime(?) AND line_time < datetime(?)) + GROUP BY bill_id + ) + SELECT u.id AS user_id, u.name AS name, u.role AS role, + COALESCE(SUM(obt.session_paid_amount), 0) AS revenue, + COUNT(b.id) AS orderCount + FROM owned_bill_totals obt + JOIN bills b ON b.id = obt.bill_id + JOIN orders o ON o.id = b.order_id + JOIN users u ON u.id = o.user_id + GROUP BY u.id + ORDER BY revenue DESC + LIMIT 20 + `, + ) + .all(session.id, session.opened_at, end) as StaffSalesRow[]) + : (db + .prepare( + ` SELECT u.id AS user_id, u.name AS name, u.role AS role, COALESCE(SUM(b.paid_amount), 0) AS revenue, COUNT(b.id) AS orderCount @@ -856,21 +913,59 @@ export function computePeriodAggregates( GROUP BY u.id ORDER BY revenue DESC LIMIT 20 - `) - .all(start, end) as StaffSalesRow[]; + `, + ) + .all(start, end) as StaffSalesRow[]); // Tax components — keyed by paid_at window to stay reconciled with the // rest of the Z. Bills are hydrated with their order items and then // aggregated via the existing `aggregateTaxComponents` pipeline, unchanged. // Unpaid bills drop out by the same logic as the staff query above. - const bills = db - .prepare(` + const bills = session + ? (db + .prepare( + ` + SELECT b.* + FROM bills b + WHERE b.paid_at IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM json_each(CASE + WHEN json_valid(b.payment_details) AND json_type(b.payment_details) = 'array' THEN b.payment_details + WHEN json_valid(b.payment_details) THEN json_array(b.payment_details) + ELSE '[]' END) je + WHERE json_type(je.value) = 'object' + AND ( + CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) = ? + OR ( + json_extract(je.value, '$.cash_session_id') IS NULL + AND COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) >= datetime(?) + AND COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) < datetime(?) + ) + ) + ) + ORDER BY b.paid_at, b.id + `, + ) + .all(session.id, session.opened_at, end) as any[]) + : (db + .prepare( + ` SELECT b.* FROM bills b WHERE b.paid_at >= ? AND b.paid_at < ? ORDER BY b.paid_at, b.id - `) - .all(start, end) as any[]; + `, + ) + .all(start, end) as any[]); const orders = getOrdersWithItemsForBills(db, bills); const taxDocuments = bills.map((bill) => ({ tax_amount: bill.tax_amount, @@ -918,7 +1013,10 @@ router.get( (req: Request, res: Response) => { try { const businessDate = validateBusinessDate(req.query.business_date); - res.json({ businessDate, movements: listCashDrawerMovements(getDatabase(), businessDate) }); + res.json({ + businessDate, + movements: listCashDrawerMovements(getDatabase(), businessDate), + }); } catch (error: any) { const status = error.statusCode || 500; if (status === 500) console.error('[CashClosures] Movement list error:', error); @@ -949,32 +1047,37 @@ router.post( const role = String((req as AuthedRequest).user?.role || ''); if (!createdBy) throw httpError('Authentication required', 401); const db = getDatabase(); - let targetSessionId: number; - if (body.cash_session_id !== undefined && body.cash_session_id !== null) { - const requestedSessionId = Number(body.cash_session_id); - if (!Number.isInteger(requestedSessionId) || requestedSessionId <= 0) { - throw httpError('cash_session_id must be a positive integer', 400); - } - const targetSession = db - .prepare(`SELECT id, opened_by FROM cash_sessions WHERE id = ? AND status = 'open'`) - .get(requestedSessionId) as { id: number; opened_by: string } | undefined; - if (!targetSession) { - throw httpError('Open shift not found', 404, 'cash_session_not_found'); - } - if (role !== 'owner' && role !== 'manager' && targetSession.opened_by !== createdBy) { - throw httpError( - 'Only an owner or manager can record movements for another staff shift', - 403, - 'session_owner_required', - ); - } - targetSessionId = targetSession.id; - } else { - // Shift enforcement (#279): every movement touches the drawer. - requireOpenSessionForCash(db, createdBy); - targetSessionId = getOpenSession(db, createdBy)?.id ?? NO_CASH_SESSION_ID; - } const id = withTxn(() => { + let targetSessionId: number; + if (body.cash_session_id !== undefined && body.cash_session_id !== null) { + const requestedSessionId = Number(body.cash_session_id); + if (!Number.isInteger(requestedSessionId) || requestedSessionId <= 0) { + throw httpError('cash_session_id must be a positive integer', 400); + } + const targetSession = db + .prepare(`SELECT id, opened_by, status FROM cash_sessions WHERE id = ?`) + .get(requestedSessionId) as + | { id: number; opened_by: string; status: string } + | undefined; + if (!targetSession) { + throw httpError('Open shift not found', 404, 'cash_session_not_found'); + } + if (targetSession.status !== 'open') { + throw httpError('Shift is already closed', 409, 'session_already_closed'); + } + if (role !== 'owner' && role !== 'manager' && targetSession.opened_by !== createdBy) { + throw httpError( + 'Only an owner or manager can record movements for another staff shift', + 403, + 'session_owner_required', + ); + } + targetSessionId = targetSession.id; + } else { + // Shift enforcement (#279): every movement touches the drawer. + requireOpenSessionForCash(db, createdBy); + targetSessionId = getOpenSession(db, createdBy)?.id ?? NO_CASH_SESSION_ID; + } let movementId: number; try { movementId = insertCashDrawerMovement(db, { @@ -1007,13 +1110,15 @@ router.post( }); operationCommitted = true; const movement = db - .prepare(` + .prepare( + ` SELECT m.*, created_user.name AS created_by_name, voided_user.name AS voided_by_name FROM cash_drawer_movements m LEFT JOIN users created_user ON created_user.id = m.created_by LEFT JOIN users voided_user ON voided_user.id = m.voided_by WHERE m.id = ? - `) + `, + ) .get(id); res.status(201).json({ movement }); } catch (error: any) { @@ -1082,22 +1187,26 @@ router.post( 409, ); } - db.prepare(` + db.prepare( + ` UPDATE cash_drawer_movements SET voided_at = ?, voided_by = ?, void_reason = ? WHERE id = ? AND voided_at IS NULL - `).run(now(), voidedBy, reason, id); + `, + ).run(now(), voidedBy, reason, id); transactionBodyCompleted = true; }); operationCommitted = true; const movement = db - .prepare(` + .prepare( + ` SELECT m.*, created_user.name AS created_by_name, voided_user.name AS voided_by_name FROM cash_drawer_movements m LEFT JOIN users created_user ON created_user.id = m.created_by LEFT JOIN users voided_user ON voided_user.id = m.voided_by WHERE m.id = ? - `) + `, + ) .get(id); res.json({ movement }); } catch (error: any) { @@ -1206,11 +1315,13 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon ); } if (recordedOpeningFloatCents === null) { - db.prepare(` + db.prepare( + ` INSERT INTO cash_drawer_movements ( business_date, movement_type, amount_cents, reason, created_by, created_at ) VALUES (?, 'opening_float', ?, 'Opening float', ?, ?) - `).run(businessDate, requestedOpeningFloatCents, closedBy, now()); + `, + ).run(businessDate, requestedOpeningFloatCents, closedBy, now()); } const aggregates = computeDayAggregates(db, businessDate); @@ -1229,7 +1340,8 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon const createdAt = now(); try { - db.prepare(` + db.prepare( + ` INSERT INTO cash_closures ( scope, business_date, period_start, period_end, opening_float_cents, expected_cash_cents, counted_cash_cents, variance_cents, @@ -1247,7 +1359,8 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon ?, ?, ?, ?, ?, ?, ?, ? ) - `).run( + `, + ).run( businessDate, periodStart, periodEnd, diff --git a/main/routes/cash-sessions.ts b/main/routes/cash-sessions.ts index 4b2f899ab..554b1f1fd 100644 --- a/main/routes/cash-sessions.ts +++ b/main/routes/cash-sessions.ts @@ -131,6 +131,7 @@ function closeSessionTxn( closedAt, sessionMovements, Number(session.opening_float_cents || 0), + session, ), // Drawer and tender totals follow ownership; staff/tax remain settlement-based. ...sessionFinancialTotals(db, session, closedAt), @@ -145,7 +146,8 @@ function closeSessionTxn( } const businessDate = sessionBusinessDate(db, session); const insert = db - .prepare(` + .prepare( + ` INSERT INTO cash_closures ( scope, business_date, period_start, period_end, opening_float_cents, expected_cash_cents, counted_cash_cents, variance_cents, @@ -163,7 +165,8 @@ function closeSessionTxn( ?, ?, ?, ?, ?, ?, ?, ? ) - `) + `, + ) .run( businessDate, session.opened_at, @@ -229,7 +232,8 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: .all(cutoff) as CashSessionRow[]; if (stale.length > 0) { const activity = db - .prepare(` + .prepare( + ` SELECT EXISTS(SELECT 1 FROM bills WHERE paid_at >= ?) OR EXISTS( @@ -244,7 +248,8 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: ) OR EXISTS(SELECT 1 FROM cash_drawer_movements WHERE created_at >= ?) OR EXISTS(SELECT 1 FROM refunds WHERE created_at >= ?) AS active - `) + `, + ) .get(cutoff, cutoff, cutoff, cutoff) as { active: number }; if (!activity.active) { for (const s of stale) { @@ -275,10 +280,12 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: throw httpError('This day is already closed', 409, 'day_already_closed'); const openedAt = now(); const insert = db - .prepare(` + .prepare( + ` INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status) VALUES (?, ?, ?, ?, 'open') - `) + `, + ) .run(openedBy, openedByRow?.name ?? openedBy, openedAt, openingFloatCents); const sessionId = Number(insert.lastInsertRowid); // Keep day close reconciling the same physical drawer: mirror the @@ -294,11 +301,13 @@ router.post('/open', requirePermission('cash.shifts.open'), (req: Request, res: ) .get(openBusinessDate) as { id: number } | undefined; if (!existingFloat) { - db.prepare(` + db.prepare( + ` INSERT INTO cash_drawer_movements ( business_date, movement_type, amount_cents, reason, created_by, created_at, cash_session_id ) VALUES (?, 'opening_float', ?, 'Shift opening float', ?, ?, ?) - `).run(openBusinessDate, openingFloatCents, openedBy, openedAt, sessionId); + `, + ).run(openBusinessDate, openingFloatCents, openedBy, openedAt, sessionId); } else if (perStaffShifts) { db.prepare( `UPDATE cash_drawer_movements SET amount_cents = amount_cents + ? WHERE id = ?`, @@ -396,9 +405,10 @@ router.get( session = getOpenSession(db, callerId); } if (!session) { - return res - .status(404) - .json({ error: 'No open shift', display_code: 'session_unavailable' }); + return res.status(404).json({ + error: 'No open shift', + display_code: 'session_unavailable', + }); } if (role !== 'owner' && role !== 'manager' && session.opened_by !== callerId) { return res.status(403).json({ diff --git a/main/routes/reports.ts b/main/routes/reports.ts index 1a05e4382..fd6d12968 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -30,6 +30,7 @@ import { sessionBusinessDate, } from '../services/cash-day-close-guards'; import type { CashSessionRow } from '../services/shift-session-gate'; +import type { AuthedRequest } from './cash-sessions'; import { computeDayAggregates, computePeriodAggregates, @@ -152,7 +153,11 @@ function bucketByLocalHourAndWeekday( timeZone: string, startTime?: string, ): { hourCounts: number[]; dayCounts: number[] } { - const hourFmt = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', hourCycle: 'h23' }); + const hourFmt = new Intl.DateTimeFormat('en-US', { + timeZone, + hour: 'numeric', + hourCycle: 'h23', + }); const hourCounts = new Array(24).fill(0); const dayCounts = new Array(7).fill(0); @@ -193,55 +198,69 @@ router.get('/daily-stats', requirePermission('reports.view'), (req: Request, res const today = reportToday(); const [start, end] = reportDayBounds(today); const salesToday = db - .prepare(` + .prepare( + ` SELECT COALESCE((SELECT SUM(paid_amount) FROM bills WHERE paid_at >= ? AND paid_at < ?), 0) - COALESCE((SELECT SUM(CAST(amount_cents AS REAL)) / ? FROM refunds WHERE created_at >= ? AND created_at < ?), 0) AS sales - `) + `, + ) .get(start, end, minorFactor, start, end) as { sales: number }; - const paymentMethodsToday = paymentMethodBreakdown(db, { startDate: today }) as { + const paymentMethodsToday = paymentMethodBreakdown(db, { + startDate: today, + }) as { total: number; }[]; const runningOrders = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as count FROM orders WHERE status IN ('pending', 'preparing') - `) + `, + ) .get() as { count: number }; const pendingOrders = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as count FROM orders WHERE status = 'pending' - `) + `, + ) .get() as { count: number }; const tablesOccupied = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as count FROM tables WHERE status = 'occupied' - `) + `, + ) .get() as { count: number }; // Avg Table Turn: how long dine-in tables took to turn over today, for // orders that finished today (mirrors /insights' avgPrepTimeMinutes idiom). const tableTurn = db - .prepare(` + .prepare( + ` SELECT AVG((julianday(completed_at) - julianday(created_at)) * 24 * 60) as avgMinutes, COUNT(*) as sampleSize FROM orders WHERE type = 'dine_in' AND status = 'completed' AND completed_at IS NOT NULL AND completed_at >= ? AND completed_at < ? - `) + `, + ) .get(start, end) as { avgMinutes: number | null; sampleSize: number }; // Avg Current Occupancy: how long tables occupied right now have been seated. const currentOccupancy = db - .prepare(` + .prepare( + ` SELECT AVG((julianday('now') - julianday(o.created_at)) * 24 * 60) as avgMinutes, COUNT(*) as sampleSize FROM tables t JOIN orders o ON o.table_id = t.id AND ${ACTIVE_ORDER_STATUS_SQL} WHERE t.status = 'occupied' - `) + `, + ) .get() as { avgMinutes: number | null; sampleSize: number }; res.json({ @@ -261,9 +280,9 @@ router.get('/daily-stats', requirePermission('reports.view'), (req: Request, res }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -277,14 +296,17 @@ router.get('/summary', requirePermission('reports.view'), (req: Request, res: Re const [start, end] = reportDayBounds(date); const ordersToday = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total FROM orders WHERE created_at >= ? AND created_at < ? AND status != 'cancelled' - `) + `, + ) .get(start, end) as { count: number; total: number }; const billsToday = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as count, COALESCE(SUM(b.total), 0) as total, COALESCE(( SELECT SUM(b2.paid_amount) @@ -302,7 +324,8 @@ router.get('/summary', requirePermission('reports.view'), (req: Request, res: Re FROM bills b JOIN orders o ON o.id = b.order_id WHERE b.created_at >= ? AND b.created_at < ? AND o.status != 'cancelled' - `) + `, + ) .get(start, end, minorFactor, start, end, start, end) as { count: number; total: number; @@ -314,15 +337,19 @@ router.get('/summary', requirePermission('reports.view'), (req: Request, res: Re }); const customersToday = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as count FROM customers WHERE created_at >= ? AND created_at < ? - `) + `, + ) .get(start, end) as { count: number }; const ordersByStatus = db - .prepare(` + .prepare( + ` SELECT status, COUNT(*) as count FROM orders WHERE created_at >= ? AND created_at < ? GROUP BY status - `) + `, + ) .all(start, end); res.json({ @@ -341,9 +368,9 @@ router.get('/summary', requirePermission('reports.view'), (req: Request, res: Re }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -360,9 +387,9 @@ router.get( (req.query.end_date !== undefined && reportDate(req.query.end_date, '') === '') ) { captureFinancialSummaryDateRangeRejection(); - return res - .status(400) - .json({ error: 'start_date and end_date must use YYYY-MM-DD format' }); + return res.status(400).json({ + error: 'start_date and end_date must use YYYY-MM-DD format', + }); } if (startDate > endDate) { captureFinancialSummaryDateRangeRejection(); @@ -373,20 +400,28 @@ router.get( const db = getDatabase(); const minorFactor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); const collections = db - .prepare(` + .prepare( + ` SELECT COUNT(*) AS bill_count, COALESCE(SUM(paid_amount), 0) AS gross_collected FROM bills WHERE paid_at >= ? AND paid_at < ? - `) + `, + ) .get(start, end) as { bill_count: number; gross_collected: number }; const refundTotals = db - .prepare(` + .prepare( + ` SELECT COUNT(*) AS refund_count, COALESCE(SUM(CAST(r.amount_cents AS REAL)) / ?, 0) AS refunded FROM refunds r JOIN bills b ON b.id = r.bill_id WHERE b.paid_at >= ? AND b.paid_at < ? - `) - .get(minorFactor, start, end) as { refund_count: number; refunded: number }; + `, + ) + .get(minorFactor, start, end) as { + refund_count: number; + refunded: number; + }; const refunds = db - .prepare(` + .prepare( + ` SELECT r.id, CAST(r.amount_cents AS REAL) / ? AS amount, r.method, r.reason, r.created_at, b.bill_number, b.paid_at, o.order_number, COALESCE(approver.name, creator.name, 'Unknown') AS approved_by_name @@ -398,7 +433,8 @@ router.get( WHERE b.paid_at >= ? AND b.paid_at < ? ORDER BY r.created_at DESC, r.id DESC LIMIT 50 - `) + `, + ) .all(minorFactor, start, end); const grossCollected = Number(collections.gross_collected || 0); const refunded = Number(refundTotals.refunded || 0); @@ -429,9 +465,9 @@ router.get( }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }, ); @@ -453,14 +489,16 @@ router.get('/tax-components', requirePermission('reports.view'), (req: Request, const windowEnd = reportDayBounds(endDate)[1]; const bills = db - .prepare(` + .prepare( + ` SELECT b.* FROM bills b JOIN orders o ON o.id = b.order_id WHERE b.created_at >= ? AND b.created_at < ? AND o.status != 'cancelled' ORDER BY b.created_at, b.id - `) + `, + ) .all(windowStart, windowEnd) as any[]; const orders = getOrdersWithItemsForBills(db, bills); @@ -483,9 +521,9 @@ router.get('/tax-components', requirePermission('reports.view'), (req: Request, }); } catch (error: any) { console.error('[API] Tax component report failed:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -537,9 +575,9 @@ router.get( } catch (error: unknown) { console.error('[API] Price exceptions report failed:', error); const failure = error as { statusCode?: number; message?: string }; - res - .status(failure.statusCode || 500) - .json({ error: failure.statusCode ? failure.message : 'Internal server error' }); + res.status(failure.statusCode || 500).json({ + error: failure.statusCode ? failure.message : 'Internal server error', + }); } }, ); @@ -562,11 +600,13 @@ router.get('/sales', requirePermission('reports.view'), (req: Request, res: Resp // Daily series is grouped by the tenant business date rather than // the UTC date stored in SQLite. const dailyRows = db - .prepare(` + .prepare( + ` SELECT created_at, total FROM orders WHERE created_at >= ? AND created_at < ? AND status != 'cancelled' - `) + `, + ) .all(windowStart, windowEnd) as { created_at: string; total: number }[]; const dailyByDate = new Map(); const timeZone = tenantTimezone(); @@ -594,12 +634,14 @@ router.get('/sales', requirePermission('reports.view'), (req: Request, res: Resp }[]; const byOrderType = db - .prepare(` + .prepare( + ` SELECT type, COUNT(*) as count, SUM(total) as total FROM orders WHERE created_at >= ? AND created_at < ? AND status != 'cancelled' GROUP BY type - `) + `, + ) .all(windowStart, windowEnd); res.json({ @@ -613,9 +655,9 @@ router.get('/sales', requirePermission('reports.view'), (req: Request, res: Resp }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -853,9 +895,9 @@ router.get('/topProducts', requirePermission('reports.view'), (req: Request, res res.json({ topProducts }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -880,11 +922,15 @@ router.get('/recentOrders', requirePermission('reports.view'), (req: Request, re (req.query.end_date !== undefined && !endDate) ) { captureReportsDateRangeRejection('invalid_format'); - return res.status(400).json({ error: 'start_date and end_date must use YYYY-MM-DD format' }); + return res.status(400).json({ + error: 'start_date and end_date must use YYYY-MM-DD format', + }); } if (date && (startDate || endDate)) { captureReportsDateRangeRejection('date_and_range_conflict'); - return res.status(400).json({ error: 'date cannot be combined with start_date or end_date' }); + return res.status(400).json({ + error: 'date cannot be combined with start_date or end_date', + }); } if (startDate && endDate && startDate > endDate) { captureReportsDateRangeRejection('reversed_range'); @@ -911,7 +957,8 @@ router.get('/recentOrders', requirePermission('reports.view'), (req: Request, re } const recentOrders = db - .prepare(` + .prepare( + ` SELECT o.*, t.number as table_name, c.name as customer_name FROM orders o LEFT JOIN tables t ON o.table_id = t.id @@ -919,7 +966,8 @@ router.get('/recentOrders', requirePermission('reports.view'), (req: Request, re ${where} ORDER BY o.created_at DESC LIMIT ? - `) + `, + ) .all(...params, limit); // #208: batch all items in one IN() query instead of per-order N+1. @@ -946,9 +994,9 @@ router.get('/recentOrders', requirePermission('reports.view'), (req: Request, re res.json({ recentOrders: ordersWithItems }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -958,7 +1006,8 @@ router.get('/tables', requirePermission('reports.view'), (req: Request, res: Res const [start, end] = reportDayBounds(reportToday()); const tableStats = db - .prepare(` + .prepare( + ` SELECT t.*, COUNT(DISTINCT o.id) as total_orders, COALESCE(SUM(o.total), 0) as total_revenue, @@ -968,11 +1017,13 @@ router.get('/tables', requirePermission('reports.view'), (req: Request, res: Res AND o.created_at >= ? AND o.created_at < ? AND o.status != 'cancelled' GROUP BY t.id - `) + `, + ) .all(start, end); const tableUtilization = db - .prepare(` + .prepare( + ` SELECT SUM(CASE WHEN status = 'occupied' THEN 1 ELSE 0 END) as occupied, SUM(CASE WHEN status = 'available' THEN 1 ELSE 0 END) as available, @@ -980,7 +1031,8 @@ router.get('/tables', requirePermission('reports.view'), (req: Request, res: Res SUM(CASE WHEN status = 'cleaning' THEN 1 ELSE 0 END) as cleaning, COUNT(*) as total FROM tables - `) + `, + ) .get(); res.json({ @@ -989,9 +1041,9 @@ router.get('/tables', requirePermission('reports.view'), (req: Request, res: Res }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -1016,14 +1068,19 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R // AOV — same revenue basis ("paid bills") as the existing daily-stats tile. const revenue = db - .prepare(` + .prepare( + ` SELECT COUNT(*) as billCount, COALESCE(SUM(paid_amount), 0) - COALESCE((SELECT SUM(CAST(amount_cents AS REAL)) / ? FROM refunds WHERE created_at >= ?), 0) as total FROM bills WHERE paid_at >= ? - `) - .get(minorFactor, windowStart, windowStart) as { billCount: number; total: number }; + `, + ) + .get(minorFactor, windowStart, windowStart) as { + billCount: number; + total: number; + }; const aov = revenue.billCount > 0 ? revenue.total / revenue.billCount : 0; // Kitchen velocity — substitutes for "best cook", which isn't derivable: @@ -1031,19 +1088,22 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R // record who did it), so there's no data to rank individual cooks by. // Average prep time is the closest real signal for kitchen performance. const prepTime = db - .prepare(` + .prepare( + ` SELECT AVG((julianday(ready_at) - julianday(cooking_started_at)) * 24 * 60) as avgMinutes, COUNT(*) as sampleSize FROM orders WHERE cooking_started_at IS NOT NULL AND ready_at IS NOT NULL AND created_at >= ? AND status != 'cancelled' - `) + `, + ) .get(windowStart) as { avgMinutes: number | null; sampleSize: number }; // Top staff by revenue — covers whoever creates orders (owner/manager/ // cashier/server, per POST /orders' own role gate), i.e. "best cashier". const topStaff = db - .prepare(` + .prepare( + ` SELECT u.id as user_id, u.name, u.role, COALESCE(SUM(o.total), 0) as revenue, COUNT(o.id) as orderCount @@ -1053,12 +1113,14 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R GROUP BY u.id ORDER BY revenue DESC LIMIT 5 - `) + `, + ) .all(windowStart); // Top categories by revenue. const topCategories = db - .prepare(` + .prepare( + ` SELECT c.id as category_id, COALESCE(c.name, 'Uncategorized') as name, COALESCE(SUM(oi.quantity), 0) as quantity, COALESCE(SUM(oi.subtotal), 0) as revenue @@ -1070,7 +1132,8 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R GROUP BY c.id ORDER BY revenue DESC LIMIT 5 - `) + `, + ) .all(windowStart); // Busiest/idlest hour & day-of-week, bucketed in the tenant's local timezone. @@ -1118,9 +1181,9 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -1193,6 +1256,32 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R if (!session) { return res.status(404).json({ error: 'Shift not found', code: 'session_unavailable' }); } + const actor = (req as AuthedRequest).user; + const actorRole = String(actor?.role || ''); + const actorUserId = String(actor?.userId || ''); + if ( + actorRole !== 'owner' && + actorRole !== 'manager' && + String(session.opened_by) !== actorUserId + ) { + return res.status(403).json({ + error: "Only owners and managers can view another staff member's shift report", + code: 'shift_access_forbidden', + }); + } + const shiftDate = sessionBusinessDate(db, session); + const sessionClosedRow = db + .prepare( + `SELECT z_number FROM cash_closures WHERE business_date = ? AND scope = 'day' LIMIT 1`, + ) + .get(shiftDate) as { z_number: number } | undefined; + const sessionPriorRow = db + .prepare( + `SELECT business_date, counted_cash_cents FROM cash_closures + WHERE business_date < ? AND scope = 'day' + ORDER BY business_date DESC LIMIT 1`, + ) + .get(shiftDate) as { business_date: string; counted_cash_cents: number } | undefined; const sessionEnd = session.closed_at || now(); const sessionMovements = listCashDrawerMovementsForSession( db, @@ -1209,13 +1298,14 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R sessionEnd, sessionMovements, openingFloatCents, + session, ), ...sessionFinancialTotals(db, session, sessionEnd), }; const totalExpectedCashCents = sessionExpectedCash(db, session, sessionEnd, sessionMovements); return res.json({ xReport: { - businessDate: sessionBusinessDate(db, session), + businessDate: shiftDate, periodStart: session.opened_at, periodEnd: sessionEnd, grossCollected: sessionAggregates.grossCollectedCents / minorFactor, @@ -1242,10 +1332,10 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R safeDropCents: sessionAggregates.safeDropCents, cashMovements: sessionAggregates.cashMovements, expectedCashCents: totalExpectedCashCents - openingFloatCents, - priorClosedCashCents: priorRow?.counted_cash_cents ?? null, - priorBusinessDate: priorRow?.business_date ?? null, - alreadyClosed: !!closedRow, - zNumber: closedRow?.z_number, + priorClosedCashCents: sessionPriorRow?.counted_cash_cents ?? null, + priorBusinessDate: sessionPriorRow?.business_date ?? null, + alreadyClosed: !!sessionClosedRow, + zNumber: sessionClosedRow?.z_number, session: { id: session.id, opened_by: session.opened_by, @@ -1324,9 +1414,9 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -1343,9 +1433,11 @@ router.get('/z-report', requirePermission('reports.view'), (req: Request, res: R .prepare(`SELECT * FROM cash_closures WHERE business_date = ? AND scope = 'day' LIMIT 1`) .get(date) as any; if (!row) { - return res - .status(404) - .json({ error: 'Day not closed', alreadyClosed: false, businessDate: date }); + return res.status(404).json({ + error: 'Day not closed', + alreadyClosed: false, + businessDate: date, + }); } // F6: resolve the operator's display name (id -> name) for the response. // Falls back to the raw id when the user row is missing. @@ -1384,9 +1476,9 @@ router.get('/z-report', requirePermission('reports.view'), (req: Request, res: R }); } catch (error: any) { console.error('[API] Internal error:', error); - res - .status(error.statusCode || 500) - .json({ error: error.statusCode ? error.message : 'Internal server error' }); + res.status(error.statusCode || 500).json({ + error: error.statusCode ? error.message : 'Internal server error', + }); } }); @@ -1589,9 +1681,9 @@ router.get( ? req.query.part : undefined; if (format === 'csv' && !part) { - return res - .status(400) - .json({ error: 'CSV export requires part=summary, part=items or part=payments' }); + return res.status(400).json({ + error: 'CSV export requires part=summary, part=items or part=payments', + }); } const dataset = buildDailySalesExportDataset(date); diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index 6305713c4..b80a80bba 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -15,7 +15,13 @@ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-cash-sessions-')); Module._load = function (request: string, parent: unknown, isMain: boolean) { if (request === 'electron') { - return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + return { + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + }, + }; } return originalLoad.apply(this, arguments as any); }; @@ -146,7 +152,9 @@ async function main() { assert(getSettingValue('one_shift_per_staff') === 'false', 'per-staff shifts default to off'); assert(getSettingValue('stale_session_days') === '7', 'stale threshold defaults to 7 days'); const movementOwnershipCols = ( - db.prepare(`PRAGMA table_info(cash_drawer_movements)`).all() as { name: string }[] + db.prepare(`PRAGMA table_info(cash_drawer_movements)`).all() as { + name: string; + }[] ).map((c) => c.name); assert(movementOwnershipCols.includes('cash_session_id'), 'movements carry cash_session_id'); const refundOwnershipCols = ( @@ -293,17 +301,17 @@ async function main() { sessionId, ); const moment = past(1800_000); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES ('ORD-SESS-1', 'cashier-sess', 'takeaway', 'completed', 100, 100, ?, ?, ?)`).run( - moment, - moment, - moment, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES ('ORD-SESS-1', 'cashier-sess', 'takeaway', 'completed', 100, 100, ?, ?, ?)`, + ).run(moment, moment, moment); const orderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-SESS-1'`).get() as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES ('SESS-1', ?, 100, 100, 100, 0, 'paid', ?, ?, ?, ?)`).run( + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES ('SESS-1', ?, 100, 100, 100, 0, 'paid', ?, ?, ?, ?)`, + ).run( orderId, JSON.stringify([{ method: 'cash', amount: 100, timestamp: moment }]), moment, @@ -411,17 +419,17 @@ async function main() { function seedUnpaidBill(tag: string): number { const m = past(600_000); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES (?, 'cashier-sess', 'takeaway', 'pending', 50, 50, ?, ?, NULL)`).run( - `ORD-ENF-${tag}`, - m, - m, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES (?, 'cashier-sess', 'takeaway', 'pending', 50, 50, ?, ?, NULL)`, + ).run(`ORD-ENF-${tag}`, m, m); const oid = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = ?`).get(`ORD-ENF-${tag}`) as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES (?, ?, 50, 50, 0, 50, 'unpaid', '[]', NULL, ?, ?)`).run(`ENF-${tag}`, oid, m, m); + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES (?, ?, 50, 50, 0, 50, 'unpaid', '[]', NULL, ?, ?)`, + ).run(`ENF-${tag}`, oid, m, m); return Number( (db.prepare(`SELECT id FROM bills WHERE bill_number = ?`).get(`ENF-${tag}`) as any).id, ); @@ -814,38 +822,48 @@ async function main() { assert(typeof sessionExpectedCash === 'function', 'session expected-cash helper exists'); const boundaryMoment = '2020-01-01 12:00:00'; const firstBoundary = db - .prepare(`INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status, closed_at, closed_by) - VALUES ('cashier-sess', 'Cashier', ?, 0, 'closed', ?, 'cashier-sess')`) + .prepare( + `INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status, closed_at, closed_by) + VALUES ('cashier-sess', 'Cashier', ?, 0, 'closed', ?, 'cashier-sess')`, + ) .run(boundaryMoment, boundaryMoment); const secondBoundary = db - .prepare(`INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status, closed_at, closed_by) - VALUES ('cashier-sess', 'Cashier', ?, 0, 'closed', ?, 'cashier-sess')`) + .prepare( + `INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status, closed_at, closed_by) + VALUES ('cashier-sess', 'Cashier', ?, 0, 'closed', ?, 'cashier-sess')`, + ) .run(boundaryMoment, boundaryMoment); const firstBoundaryId = Number(firstBoundary.lastInsertRowid); const secondBoundaryId = Number(secondBoundary.lastInsertRowid); - db.prepare(`INSERT INTO cash_drawer_movements (business_date, movement_type, amount_cents, reason, created_by, created_at, cash_session_id) - VALUES ('2020-01-01', 'pay_in', 1000, 'boundary', 'cashier-sess', ?, ?)`).run( - boundaryMoment, - firstBoundaryId, - ); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES ('ORD-BOUND-1', 'cashier-sess', 'takeaway', 'pending', 50, 50, '2019-12-31 12:00:00', '2019-12-31 12:00:00', NULL)`).run(); + db.prepare( + `INSERT INTO cash_drawer_movements (business_date, movement_type, amount_cents, reason, created_by, created_at, cash_session_id) + VALUES ('2020-01-01', 'pay_in', 1000, 'boundary', 'cashier-sess', ?, ?)`, + ).run(boundaryMoment, firstBoundaryId); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES ('ORD-BOUND-1', 'cashier-sess', 'takeaway', 'pending', 50, 50, '2019-12-31 12:00:00', '2019-12-31 12:00:00', NULL)`, + ).run(); const boundaryOrderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-BOUND-1'`).get() as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES ('BOUND-1', ?, 50, 50, 20, 30, 'partial', ?, NULL, '2019-12-31 12:00:00', '2019-12-31 12:00:00')`).run( + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES ('BOUND-1', ?, 50, 50, 20, 30, 'partial', ?, NULL, '2019-12-31 12:00:00', '2019-12-31 12:00:00')`, + ).run( boundaryOrderId, JSON.stringify([ - { method: 'cash', amount: 20, timestamp: boundaryMoment, cash_session_id: firstBoundaryId }, + { + method: 'cash', + amount: 20, + timestamp: boundaryMoment, + cash_session_id: firstBoundaryId, + }, ]), ); - db.prepare(`INSERT INTO refunds (bill_id, amount_cents, method, approved_by, created_by, created_at, cash_session_id) - VALUES (?, 500, 'cash', 'owner-sess', 'cashier-sess', ?, ?)`).run( - boundaryOrderId, - boundaryMoment, - firstBoundaryId, - ); + db.prepare( + `INSERT INTO refunds (bill_id, amount_cents, method, approved_by, created_by, created_at, cash_session_id) + VALUES (?, 500, 'cash', 'owner-sess', 'cashier-sess', ?, ?)`, + ).run(boundaryOrderId, boundaryMoment, firstBoundaryId); const firstBoundaryCash = sessionExpectedCash( db, { id: firstBoundaryId, opening_float_cents: 0 }, @@ -935,20 +953,17 @@ async function main() { 'settled carry-over bill records cash_session_id of the shift that collected the payment', ); const cancelledMoment = past(600_000); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES ('ORD-CANCELLED-SESS', 'cashier-sess', 'takeaway', 'cancelled', 50, 50, ?, ?, NULL)`).run( - cancelledMoment, - cancelledMoment, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES ('ORD-CANCELLED-SESS', 'cashier-sess', 'takeaway', 'cancelled', 50, 50, ?, ?, NULL)`, + ).run(cancelledMoment, cancelledMoment); const cancelledOrderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-CANCELLED-SESS'`).get() as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES ('CANCELLED-SESS', ?, 50, 50, 0, 0, 'unpaid', '[]', NULL, ?, ?)`).run( - cancelledOrderId, - cancelledMoment, - cancelledMoment, - ); + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES ('CANCELLED-SESS', ?, 50, 50, 0, 0, 'unpaid', '[]', NULL, ?, ?)`, + ).run(cancelledOrderId, cancelledMoment, cancelledMoment); db.prepare(`UPDATE cash_sessions SET opened_at = ? WHERE id = ?`).run( past(3600_000), cancelledSessionId, @@ -976,37 +991,42 @@ async function main() { shift1Id = Number(res.body?.id); }); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at) - VALUES ('ORD-GUARD-SAME', 'cashier-sess', 'takeaway', 'pending', 50, 50, ?, ?)`).run( - tClose, - tClose, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at) + VALUES ('ORD-GUARD-SAME', 'cashier-sess', 'takeaway', 'pending', 50, 50, ?, ?)`, + ).run(tClose, tClose); const sameOrderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-GUARD-SAME'`).get() as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, created_at, updated_at) - VALUES ('BILL-GUARD-SAME', ?, 50, 50, 20, 30, 'partial', ?, ?, ?)`).run( + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, created_at, updated_at) + VALUES ('BILL-GUARD-SAME', ?, 50, 50, 20, 30, 'partial', ?, ?, ?)`, + ).run( sameOrderId, - JSON.stringify([{ method: 'cash', amount: 20, timestamp: tClose, cash_session_id: shift1Id }]), + JSON.stringify([ + { + method: 'cash', + amount: 20, + timestamp: tClose, + cash_session_id: shift1Id, + }, + ]), tClose, tClose, ); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at) - VALUES ('ORD-GUARD-CANCELLED', 'cashier-sess', 'takeaway', 'cancelled', 50, 50, ?, ?)`).run( - tClose, - tClose, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at) + VALUES ('ORD-GUARD-CANCELLED', 'cashier-sess', 'takeaway', 'cancelled', 50, 50, ?, ?)`, + ).run(tClose, tClose); const cancelOrderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-GUARD-CANCELLED'`).get() as any) .id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, created_at, updated_at) - VALUES ('BILL-GUARD-CANCELLED', ?, 50, 50, 0, 50, 'unpaid', '[]', ?, ?)`).run( - cancelOrderId, - tClose, - tClose, - ); + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, created_at, updated_at) + VALUES ('BILL-GUARD-CANCELLED', ?, 50, 50, 0, 50, 'unpaid', '[]', ?, ?)`, + ).run(cancelOrderId, tClose, tClose); await withFixedClock(tCloseIso, async () => { const previewRes = await request(app) @@ -1076,10 +1096,10 @@ async function main() { db.prepare(`UPDATE cash_sessions SET opened_at = datetime('now', '-8 days') WHERE id = ?`).run( activeStaleId, ); - db.prepare(`INSERT INTO cash_drawer_movements (business_date, movement_type, amount_cents, reason, created_by, created_at) - VALUES (?, 'pay_in', 1000, 'recent activity', 'cashier-sess', datetime('now'))`).run( - todayLocal, - ); + db.prepare( + `INSERT INTO cash_drawer_movements (business_date, movement_type, amount_cents, reason, created_by, created_at) + VALUES (?, 'pay_in', 1000, 'recent activity', 'cashier-sess', datetime('now'))`, + ).run(todayLocal); const activeStaleBlocked = await request(app) .post('/api/cash-sessions/open') .set('Authorization', `Bearer ${cashierToken}`) @@ -1280,7 +1300,11 @@ async function main() { `INSERT INTO users (id, name, email, password, role, is_active, created_at, updated_at) VALUES ('chef-sess', 'Chef', 'chef-sess@test.local', ?, 'chef', 1, ?, ?)`, ).run(bcrypt.hashSync('pw', 10), now(), now()); const managerToken = jwt.sign( - { userId: 'manager-sess', email: 'manager-sess@test.local', role: 'manager' }, + { + userId: 'manager-sess', + email: 'manager-sess@test.local', + role: 'manager', + }, getJWTSecret(), { expiresIn: '1h' }, ); @@ -1382,7 +1406,11 @@ async function main() { const dayClose = await request(app) .post('/api/cash-closures') .set('Authorization', `Bearer ${ownerToken}`) - .send({ business_date: todayLocal, opening_float_cents: 5000, counted_cash_cents: 5000 }); + .send({ + business_date: todayLocal, + opening_float_cents: 5000, + counted_cash_cents: 5000, + }); assert(dayClose.status === 201, 'owner day-close for gate test (201)'); const managerDayPrint = await request(app) .post(`/api/cash-closures/${dayClose.body?.zReport?.id}/print`) @@ -1400,7 +1428,11 @@ async function main() { const blockedDayClose = await request(app) .post('/api/cash-closures') .set('Authorization', `Bearer ${ownerToken}`) - .send({ business_date: todayLocal, opening_float_cents: 0, counted_cash_cents: 0 }); + .send({ + business_date: todayLocal, + opening_float_cents: 0, + counted_cash_cents: 0, + }); assert( blockedDayClose.status === 409, `day close blocked while shift open (409, got ${blockedDayClose.status})`, @@ -1460,20 +1492,17 @@ async function main() { assert(turnB.status === 200, 'phase1: second shift opens on same commercial day'); const turnBId = Number(turnB.body?.id); const carryMoment = now(); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES ('ORD-CARRY-PH1', 'cashier-sess', 'takeaway', 'pending', 50, 50, ?, ?, NULL)`).run( - carryMoment, - carryMoment, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES ('ORD-CARRY-PH1', 'cashier-sess', 'takeaway', 'pending', 50, 50, ?, ?, NULL)`, + ).run(carryMoment, carryMoment); const carryOrderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-CARRY-PH1'`).get() as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES ('CARRY-PH1', ?, 50, 50, 0, 50, 'unpaid', '[]', NULL, ?, ?)`).run( - carryOrderId, - carryMoment, - carryMoment, - ); + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES ('CARRY-PH1', ?, 50, 50, 0, 50, 'unpaid', '[]', NULL, ?, ?)`, + ).run(carryOrderId, carryMoment, carryMoment); const closeTurnB = await request(app) .post(`/api/cash-sessions/${turnBId}/close`) .set('Authorization', `Bearer ${cashierToken}`) @@ -1511,23 +1540,17 @@ async function main() { ); // Partial bill: 50 total, 20 paid, 30 outstanding. const pm = past(1800_000); - db.prepare(`INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) - VALUES ('ORD-PART-1', 'cashier-sess', 'takeaway', 'completed', 50, 50, ?, ?, ?)`).run( - pm, - pm, - pm, - ); + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES ('ORD-PART-1', 'cashier-sess', 'takeaway', 'completed', 50, 50, ?, ?, ?)`, + ).run(pm, pm, pm); const partOrderId = Number( (db.prepare(`SELECT id FROM orders WHERE order_number = 'ORD-PART-1'`).get() as any).id, ); - db.prepare(`INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) - VALUES ('PART-1', ?, 50, 50, 20, 30, 'partial', ?, ?, ?, ?)`).run( - partOrderId, - JSON.stringify([{ method: 'cash', amount: 20, timestamp: pm }]), - pm, - pm, - pm, - ); + db.prepare( + `INSERT INTO bills (bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, payment_details, paid_at, created_at, updated_at) + VALUES ('PART-1', ?, 50, 50, 20, 30, 'partial', ?, ?, ?, ?)`, + ).run(partOrderId, JSON.stringify([{ method: 'cash', amount: 20, timestamp: pm }]), pm, pm, pm); const partPreview = await request(app) .get('/api/cash-sessions/current/close-preview') .set('Authorization', `Bearer ${cashierToken}`); @@ -1595,7 +1618,12 @@ async function main() { const rfBlocked = await request(app) .post('/api/refunds') .set('Authorization', `Bearer ${managerToken}`) - .send({ bill_id: rfBillId, amount: 10, method: 'cash', approver_id: 'manager-sess' }); + .send({ + bill_id: rfBillId, + amount: 10, + method: 'cash', + approver_id: 'manager-sess', + }); assert(rfBlocked.status === 409, 'enforcement on: cash refund without a session is 409'); assert( /shift/i.test(String(rfBlocked.body?.error || '')), @@ -1622,7 +1650,9 @@ async function main() { const customPay = await request(app) .post(`/api/bills/${customBill}/payments`) .set('Authorization', `Bearer ${cashierToken}`) - .send({ payments: [{ method: 'custom', payment_method_id: customId, amount: 50 }] }); + .send({ + payments: [{ method: 'custom', payment_method_id: customId, amount: 50 }], + }); assert(customPay.status === 200, 'configured Cash tender settles with an open session'); const customCurrent = await request(app) .get('/api/cash-sessions/current') @@ -1700,12 +1730,20 @@ async function main() { const closePriorDay = await request(app) .post('/api/cash-closures') .set('Authorization', `Bearer ${ownerToken}`) - .send({ business_date: '2026-06-13', opening_float_cents: 0, counted_cash_cents: 0 }); + .send({ + business_date: '2026-06-13', + opening_float_cents: 0, + counted_cash_cents: 0, + }); assert(closePriorDay.status === 201, 'owner closes prior business day 2026-06-13 (201)'); const closeCalendarDay = await request(app) .post('/api/cash-closures') .set('Authorization', `Bearer ${ownerToken}`) - .send({ business_date: '2026-06-15', opening_float_cents: 0, counted_cash_cents: 0 }); + .send({ + business_date: '2026-06-15', + opening_float_cents: 0, + counted_cash_cents: 0, + }); assert( closeCalendarDay.status === 201, 'owner closes calendar date 2026-06-15 before boundary test (201)', @@ -1746,17 +1784,25 @@ async function main() { const closedEffectiveDayRes = await request(app) .post('/api/cash-closures') .set('Authorization', `Bearer ${ownerToken}`) - .send({ business_date: '2026-06-14', opening_float_cents: 2500, counted_cash_cents: 2500 }); + .send({ + business_date: '2026-06-14', + opening_float_cents: 2500, + counted_cash_cents: 2500, + }); assert( closedEffectiveDayRes.status === 201, 'owner closes effective business day 2026-06-14 (201)', ); const sessionsBefore = ( - db.prepare(`SELECT COUNT(*) AS c FROM cash_sessions`).get() as { c: number } + db.prepare(`SELECT COUNT(*) AS c FROM cash_sessions`).get() as { + c: number; + } ).c; const movementsBefore = ( - db.prepare(`SELECT COUNT(*) AS c FROM cash_drawer_movements`).get() as { c: number } + db.prepare(`SELECT COUNT(*) AS c FROM cash_drawer_movements`).get() as { + c: number; + } ).c; const closedDayPositiveFloatOpen = await request(app) @@ -1824,7 +1870,11 @@ async function main() { const { escPosToText } = require('../main/printers/thermal'); const { computeDayAggregates } = require('../main/routes/cash-closures'); const longLivedCashierToken = jwt.sign( - { userId: 'cashier-sess', email: 'cashier-sess@test.local', role: 'cashier' }, + { + userId: 'cashier-sess', + email: 'cashier-sess@test.local', + role: 'cashier', + }, getJWTSecret(), ); @@ -1959,7 +2009,11 @@ async function main() { const sessionRow = db .prepare(`SELECT opened_at, closed_at, status FROM cash_sessions WHERE id = ?`) - .get(sessionId) as { opened_at: string; closed_at: string; status: string }; + .get(sessionId) as { + opened_at: string; + closed_at: string; + status: string; + }; const closureRow = db .prepare( `SELECT scope, business_date, period_start, period_end, opening_float_cents, expected_cash_cents, counted_cash_cents, variance_cents, gross_collected_cents, net_collected_cents, bill_count, z_number FROM cash_closures WHERE id = ?`, @@ -2281,17 +2335,29 @@ async function main() { app.use('/api/expenses', expenseRoutes); app.use('/api/reports', reportRoutes); const s11CashierAToken = jwt.sign( - { userId: 'cashier-sess', email: 'cashier-sess@test.local', role: 'cashier' }, + { + userId: 'cashier-sess', + email: 'cashier-sess@test.local', + role: 'cashier', + }, getJWTSecret(), { expiresIn: '365d' }, ); const s11CashierBToken = jwt.sign( - { userId: 'cashier2-sess', email: 'cashier2-sess@test.local', role: 'cashier' }, + { + userId: 'cashier2-sess', + email: 'cashier2-sess@test.local', + role: 'cashier', + }, getJWTSecret(), { expiresIn: '365d' }, ); const s11ManagerToken = jwt.sign( - { userId: 'manager-sess', email: 'manager-sess@test.local', role: 'manager' }, + { + userId: 'manager-sess', + email: 'manager-sess@test.local', + role: 'manager', + }, getJWTSecret(), { expiresIn: '365d' }, ); @@ -2592,7 +2658,11 @@ async function main() { 'SELECT cash_session_id, amount_cents, movement_type FROM cash_drawer_movements WHERE id = ?', ) .get(expMovId) as - | { cash_session_id: number; amount_cents: number; movement_type: string } + | { + cash_session_id: number; + amount_cents: number; + movement_type: string; + } | undefined; assert( Number(expMovRow?.cash_session_id) === shiftBId && @@ -2631,6 +2701,27 @@ async function main() { Number(detailsMgr[0]?.cash_session_id) === 0, '#1339: sessionless actor payment stores cash_session_id = 0 (NO_CASH_SESSION_ID) and does not leak into Shift A or B', ); + // If the manager subsequently opens a shift and attempts to correct that sessionless payment line (cash_session_id === 0), + // it must be rejected with 409 shift_closed rather than moving into the manager's open shift. + const openMgrShift = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ opening_float_cents: 0 }); + assert(openMgrShift.status === 200, '#1339: manager opens a shift (200)'); + const correctSessionless = await request(app) + .patch(`/api/bills/${billMgrId}/payments/0/method`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ + method: 'card', + reason: 'Wrong button', + approver_id: 'owner-sess', + override_pin: '1234', + }); + assert( + correctSessionless.status === 409 && correctSessionless.body?.reason === 'shift_closed', + `#1339: correcting a sessionless payment line (cash_session_id=0) is rejected with 409 shift_closed (got ${correctSessionless.status} ${correctSessionless.body?.reason})`, + ); + db.prepare('DELETE FROM cash_sessions WHERE id = ?').run(openMgrShift.body?.id); // Delete the unscoped manager test bill so day close compares purely Shift A + Shift B db.prepare('DELETE FROM bills WHERE id = ?').run(billMgrId); db.prepare(`DELETE FROM orders WHERE order_number = 'ORD-1339-MGR'`).run(); @@ -2710,8 +2801,12 @@ async function main() { xShiftARep?.session?.expected_cash_cents === 9000 && xShiftARep?.grossCollected === 40 && xShiftARep?.refunded === 10 && - xShiftARep?.netCollected === 30, - `#1339: X report with ?session_id=${shiftAId} scopes to Shift A (expected_cash_cents=9000, got ${xShiftARep?.session?.expected_cash_cents})`, + xShiftARep?.netCollected === 30 && + Array.isArray(xShiftARep?.staffSales) && + xShiftARep.staffSales.length === 1 && + Number(xShiftARep.staffSales[0]?.revenue) === 40 && + Number(xShiftARep.staffSales[0]?.orderCount) === 1, + `#1339: X report with ?session_id=${shiftAId} scopes totals and staffSales to Shift A (expected_cash_cents=9000, got ${xShiftARep?.session?.expected_cash_cents})`, ); const xShiftB = await request(app) @@ -2727,10 +2822,38 @@ async function main() { xShiftBRep?.grossCollected === 60 && xShiftBRep?.refunded === 0 && xShiftBRep?.netCollected === 60 && - xShiftBRep?.payOutCents === 2000, - `#1339: X report with ?session_id=${shiftBId} scopes to Shift B (expected_cash_cents=7000, payOut=2000, got ${xShiftBRep?.session?.expected_cash_cents})`, + xShiftBRep?.payOutCents === 2000 && + Array.isArray(xShiftBRep?.staffSales) && + xShiftBRep.staffSales.length === 1 && + Number(xShiftBRep.staffSales[0]?.revenue) === 60 && + Number(xShiftBRep.staffSales[0]?.orderCount) === 1, + `#1339: X report with ?session_id=${shiftBId} scopes totals and staffSales to Shift B (expected_cash_cents=7000, payOut=2000, got ${xShiftBRep?.session?.expected_cash_cents})`, ); + // Cashier with reports.view can view their own shift X report (200) but is forbidden from another user's shift X report (403) + db.prepare( + `INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, updated_at) + VALUES ('cashier-sess', 'reports.view', 'allow', 'owner-sess', ?) + ON CONFLICT(user_id, permission_id) DO UPDATE SET effect = 'allow', updated_at = excluded.updated_at`, + ).run(now()); + const xOwnCashier = await request(app) + .get(`/api/reports/x-report?date=2026-11-10&session_id=${shiftAId}`) + .set('Authorization', `Bearer ${s11CashierAToken}`); + assert( + xOwnCashier.status === 200 && Number(xOwnCashier.body?.xReport?.session?.id) === shiftAId, + '#1339: Cashier A with reports.view can view own shift X report (200)', + ); + const xOtherCashier = await request(app) + .get(`/api/reports/x-report?date=2026-11-10&session_id=${shiftBId}`) + .set('Authorization', `Bearer ${s11CashierAToken}`); + assert( + xOtherCashier.status === 403 && xOtherCashier.body?.code === 'shift_access_forbidden', + `#1339: Cashier A cannot view Cashier B shift X report (403 shift_access_forbidden, got ${xOtherCashier.status})`, + ); + db.prepare( + `DELETE FROM user_permission_overrides WHERE user_id = 'cashier-sess' AND permission_id = 'reports.view'`, + ).run(); + // 7) Close authority & Day Close waiting until ALL shifts are closed // Cashier A cannot preview or close Cashier B's shift const crossPreview = await request(app) diff --git a/tests/pin-quick-switch.test.ts b/tests/pin-quick-switch.test.ts index f35f7c805..2e13dfc84 100644 --- a/tests/pin-quick-switch.test.ts +++ b/tests/pin-quick-switch.test.ts @@ -115,7 +115,9 @@ const { refundRoutes } = require('../main/routes/refunds'); const { resetSuccessfulLogin } = require('../main/middleware/security'); function makeToken(id: string, role: string, email: string) { - const token = jwt.sign({ userId: id, email, role }, getJWTSecret(), { expiresIn: '1h' }); + const token = jwt.sign({ userId: id, email, role }, getJWTSecret(), { + expiresIn: '1h', + }); return { Authorization: `Bearer ${token}` }; } @@ -133,26 +135,34 @@ async function run() { ).run(ts); // Seed owner (PIN 2468) and manager (PIN 1357) - db.prepare(` + db.prepare( + ` INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) VALUES ('owner-1', 'Alice Owner', 'owner@test.local', ?, 'owner', ?, 1, ?, ?) - `).run(bcrypt.hashSync('OwnerPass1!', 10), bcrypt.hashSync('2468', 10), ts, ts); + `, + ).run(bcrypt.hashSync('OwnerPass1!', 10), bcrypt.hashSync('2468', 10), ts, ts); - db.prepare(` + db.prepare( + ` INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) VALUES ('mgr-1', 'Bob Manager', 'manager@test.local', ?, 'manager', ?, 1, ?, ?) - `).run(bcrypt.hashSync('MgrPass1!', 10), bcrypt.hashSync('1357', 10), ts, ts); + `, + ).run(bcrypt.hashSync('MgrPass1!', 10), bcrypt.hashSync('1357', 10), ts, ts); // Seed user without PIN (server) and inactive user with PIN - db.prepare(` + db.prepare( + ` INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) VALUES ('server-no-pin', 'NoPin Server', 'server@test.local', ?, 'server', NULL, 1, ?, ?) - `).run(bcrypt.hashSync('SrvPass1!', 10), ts, ts); + `, + ).run(bcrypt.hashSync('SrvPass1!', 10), ts, ts); - db.prepare(` + db.prepare( + ` INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) VALUES ('inactive-pin', 'Inactive Chef', 'inactive@test.local', ?, 'chef', ?, 0, ?, ?) - `).run(bcrypt.hashSync('ChefPass1!', 10), bcrypt.hashSync('9999', 10), ts, ts); + `, + ).run(bcrypt.hashSync('ChefPass1!', 10), bcrypt.hashSync('9999', 10), ts, ts); const ownerAuth = makeToken('owner-1', 'owner', 'owner@test.local'); const managerAuth = makeToken('mgr-1', 'manager', 'manager@test.local'); @@ -262,7 +272,11 @@ async function run() { .prepare( "SELECT user_id, outcome, details_json FROM auth_audit_log WHERE event_type = 'pin_login' AND outcome = 'failure' ORDER BY id ASC", ) - .all() as Array<{ user_id: string | null; outcome: string; details_json: string | null }>; + .all() as Array<{ + user_id: string | null; + outcome: string; + details_json: string | null; + }>; assertOrThrow( missingAuditRows.some( (r) => r.user_id === null && String(r.details_json).includes('missing_user_id'), @@ -360,6 +374,31 @@ async function run() { ); resetSuccessfulLogin(`127.0.0.1:pin:${cashierId}`); + // Rotating source IP cannot bypass the 5-attempt per-account lockout (pin-user:) + app.set('trust proxy', true); + for (let attempt = 1; attempt <= 5; attempt++) { + const resIp = await request(app) + .post('/api/auth/pin-login') + .set('X-Forwarded-For', `10.10.0.${attempt}`) + .send({ user_id: cashierId, pin: '0000' }); + assertEqualOrThrow( + resIp.status, + 401, + `Rotated IP attempt ${attempt} (10.10.0.${attempt}) returns 401`, + ); + } + const lockedAcrossIps = await request(app) + .post('/api/auth/pin-login') + .set('X-Forwarded-For', '10.10.0.99') + .send({ user_id: cashierId, pin: '2468' }); + assertEqualOrThrow( + lockedAcrossIps.status, + 429, + '6th attempt from a fresh source IP returns 429 due to per-account lockout', + ); + app.set('trust proxy', false); + resetSuccessfulLogin(`127.0.0.1:pin:${cashierId}`); + // ── 5. Cashier PIN rejected on EVERY approval endpoint ── console.log('\n5. Cashier PIN rejected on every approval endpoint'); const cashierAuth = makeToken(cashierId, 'cashier', 'cashier@test.local'); @@ -395,10 +434,12 @@ async function run() { // Even when a store grants cashier route permissions for discounts, item cancel, and refunds, // cashier PINs must STILL be rejected on every PIN approval check (only owner/manager PINs approve). for (const permId of ['bills.discount.apply', 'orders.item.cancel', 'refunds.initiate']) { - db.prepare(` + db.prepare( + ` INSERT OR REPLACE INTO role_permission_overrides (role, permission_id, effect, updated_by, updated_at) VALUES ('cashier', ?, 'allow', 'owner-1', ?) - `).run(permId, ts); + `, + ).run(permId, ts); } // Seed an active order + item + bill for discount, void, status, kitchen-delivery, charge waiver, and refund tests @@ -443,26 +484,32 @@ async function run() { }, ]); - db.prepare(` + db.prepare( + ` INSERT INTO orders (order_number, type, table_id, subtotal, tax_amount, service_charge, charges_breakdown, total, status, user_id, created_at, updated_at) VALUES ('ORD-PIN-001', 'dine_in', ?, 100, 0, 10, ?, 110, 'preparing', ?, ?, ?) - `).run(tableId, initialChargesBreakdown, cashierId, ts, ts); + `, + ).run(tableId, initialChargesBreakdown, cashierId, ts, ts); const orderId = ( db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-PIN-001'").get() as any ).id; - db.prepare(` + db.prepare( + ` INSERT INTO order_items (order_id, product_id, product_name, quantity, unit_price, subtotal, tax_amount, total, status, created_at, updated_at) VALUES (?, ?, 'Espresso', 1, 100, 100, 0, 100, 'preparing', ?, ?) - `).run(orderId, prodId, ts, ts); + `, + ).run(orderId, prodId, ts, ts); const orderItemId = ( db.prepare('SELECT id FROM order_items WHERE order_id = ?').get(orderId) as any ).id; - db.prepare(` + db.prepare( + ` INSERT INTO bills (bill_number, order_id, subtotal, tax_amount, service_charge, packaging_charge, charges_breakdown, total, paid_amount, balance, payment_status, created_at, updated_at) VALUES ('BILL-PIN-001', ?, 100, 0, 10, 0, ?, 110, 0, 110, 'unpaid', ?, ?) - `).run(orderId, initialChargesBreakdown, ts, ts); + `, + ).run(orderId, initialChargesBreakdown, ts, ts); const billId = ( db.prepare("SELECT id FROM bills WHERE bill_number = 'BILL-PIN-001'").get() as any ).id; @@ -482,7 +529,12 @@ async function run() { const billDiscCashierTargeted = await request(app) .post(`/api/bills/${billId}/discount`) .set(cashierAuth) - .send({ type: 'percentage', value: 10, approver_id: cashierId, override_pin: '7777' }); + .send({ + type: 'percentage', + value: 10, + approver_id: cashierId, + override_pin: '7777', + }); assertEqualOrThrow( billDiscCashierTargeted.status, 403, @@ -494,13 +546,21 @@ async function run() { const orderDiscCashier = await request(app) .patch(`/api/orders/${orderId}/discount`) .set(cashierAuth) - .send({ discount_type: 'percentage', discount_value: 10, override_pin: '7777' }); + .send({ + discount_type: 'percentage', + discount_value: 10, + override_pin: '7777', + }); assertEqualOrThrow(orderDiscCashier.status, 403, 'Order discount rejects cashier PIN with 403'); const itemDiscCashier = await request(app) .patch(`/api/orders/${orderId}/items/${orderItemId}/discount`) .set(cashierAuth) - .send({ discount_type: 'percentage', discount_value: 10, override_pin: '7777' }); + .send({ + discount_type: 'percentage', + discount_value: 10, + override_pin: '7777', + }); assertEqualOrThrow( itemDiscCashier.status, 403, @@ -522,24 +582,32 @@ async function run() { const itemVoidCashierTargeted = await request(app) .patch(`/api/orders/${orderId}/items/${orderItemId}/cancel`) .set(cashierAuth) - .send({ reason: 'Customer changed mind', approver_id: cashierId, override_pin: '7777' }); + .send({ + reason: 'Customer changed mind', + approver_id: cashierId, + override_pin: '7777', + }); assertEqualOrThrow( itemVoidCashierTargeted.status, 403, 'In-progress item void rejects cashier PIN (targeted approver_id) with 403', ); - db.prepare(` + db.prepare( + ` INSERT INTO orders (order_number, type, status, subtotal, tax_amount, total, created_at, updated_at) VALUES ('ORD-PIN-VOID-002', 'dine_in', 'preparing', 100, 0, 100, ?, ?) - `).run(ts, ts); + `, + ).run(ts, ts); const voidOrder2Id = ( db.prepare("SELECT id FROM orders WHERE order_number = 'ORD-PIN-VOID-002'").get() as any ).id; - db.prepare(` + db.prepare( + ` INSERT INTO order_items (order_id, product_id, product_name, quantity, unit_price, subtotal, tax_amount, total, status, created_at, updated_at) VALUES (?, ?, 'Espresso', 1, 100, 100, 0, 100, 'preparing', ?, ?) - `).run(voidOrder2Id, prodId, ts, ts); + `, + ).run(voidOrder2Id, prodId, ts, ts); const voidOrder2ItemId = ( db.prepare('SELECT id FROM order_items WHERE order_id = ?').get(voidOrder2Id) as any ).id; @@ -574,7 +642,11 @@ async function run() { const orderCompleteCashier = await request(app) .patch(`/api/orders/${orderId}/status`) .set(cashierAuth) - .send({ status: 'completed', approver_id: cashierId, override_pin: '7777' }); + .send({ + status: 'completed', + approver_id: cashierId, + override_pin: '7777', + }); assertEqualOrThrow( orderCompleteCashier.status, 403, @@ -905,7 +977,12 @@ async function run() { // Resolve stale /auth/me from owner-1's previous session and confirm it is ignored staleMeResolver({ data: { - user: { id: 'owner-1', name: 'Alice Owner', email: 'owner@test.local', role: 'owner' }, + user: { + id: 'owner-1', + name: 'Alice Owner', + email: 'owner@test.local', + role: 'owner', + }, tenants: [{ id: 1, business_name: 'Test Cafe', role: 'owner' }], }, }); From ff6b5e5b1062beeffb216566974feaa26d43f376 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 13:59:28 -0400 Subject: [PATCH 18/22] no-mistakes(review): fix(cash): scope shift x-report closure metadata to session --- main/routes/reports.ts | 97 +++++++++++++++++++++---------------- tests/cash-sessions.test.ts | 31 ++++++++++++ 2 files changed, 86 insertions(+), 42 deletions(-) diff --git a/main/routes/reports.ts b/main/routes/reports.ts index fd6d12968..12c3fe324 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -1211,39 +1211,8 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R // float. router.get('/x-report', requirePermission('reports.view'), (req: Request, res: Response) => { try { - const today = reportToday(); - const date = reportDate(req.query.date, today); const db = getDatabase(); const minorFactor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); - const [periodStart, periodEnd] = reportDayBounds(date); - - const aggregates = computeDayAggregates(db, date); - - const closedRow = db - .prepare( - `SELECT z_number FROM cash_closures WHERE business_date = ? AND scope = 'day' LIMIT 1`, - ) - .get(date) as { z_number: number } | undefined; - - // F3: most recent prior closed day for this business date, used by the - // modal to prefill the opening float (server-side one-shot query; the - // frontend no longer walks back day-by-day). - const priorRow = db - .prepare( - `SELECT business_date, counted_cash_cents FROM cash_closures - WHERE business_date < ? AND scope = 'day' - ORDER BY business_date DESC LIMIT 1`, - ) - .get(date) as { business_date: string; counted_cash_cents: number } | undefined; - - const blockingSessions = openSessionsBlockingDayClose(db, date); - const blockingSession = blockingSessions[0]; - const openShifts = blockingSessions.map((s) => ({ - id: s.id, - opened_by: s.opened_by, - opened_by_name: s.opened_by_name, - opened_at: s.opened_at, - })); if (req.query.session_id !== undefined && String(req.query.session_id).trim() !== '') { const sessionId = Number(req.query.session_id); @@ -1270,11 +1239,14 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R }); } const shiftDate = sessionBusinessDate(db, session); - const sessionClosedRow = db - .prepare( - `SELECT z_number FROM cash_closures WHERE business_date = ? AND scope = 'day' LIMIT 1`, - ) - .get(shiftDate) as { z_number: number } | undefined; + const sessionClosedRow = + session.closure_id != null + ? (db + .prepare( + `SELECT z_number FROM cash_closures WHERE id = ? AND scope = 'session' LIMIT 1`, + ) + .get(session.closure_id) as { z_number: number } | undefined) + : undefined; const sessionPriorRow = db .prepare( `SELECT business_date, counted_cash_cents FROM cash_closures @@ -1282,6 +1254,14 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R ORDER BY business_date DESC LIMIT 1`, ) .get(shiftDate) as { business_date: string; counted_cash_cents: number } | undefined; + const sessionBlockingSessions = openSessionsBlockingDayClose(db, shiftDate); + const sessionBlocking = sessionBlockingSessions[0]; + const sessionOpenShifts = sessionBlockingSessions.map((s) => ({ + id: s.id, + opened_by: s.opened_by, + opened_by_name: s.opened_by_name, + opened_at: s.opened_at, + })); const sessionEnd = session.closed_at || now(); const sessionMovements = listCashDrawerMovementsForSession( db, @@ -1334,7 +1314,7 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R expectedCashCents: totalExpectedCashCents - openingFloatCents, priorClosedCashCents: sessionPriorRow?.counted_cash_cents ?? null, priorBusinessDate: sessionPriorRow?.business_date ?? null, - alreadyClosed: !!sessionClosedRow, + alreadyClosed: session.status === 'closed' || !!sessionClosedRow, zNumber: sessionClosedRow?.z_number, session: { id: session.id, @@ -1343,21 +1323,54 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R opened_at: session.opened_at, closed_at: session.closed_at, status: session.status, + closure_id: session.closure_id, opening_float_cents: openingFloatCents, expected_cash_cents: totalExpectedCashCents, }, - openShifts, - blockingOpenShift: blockingSession + openShifts: sessionOpenShifts, + blockingOpenShift: sessionBlocking ? { - id: blockingSession.id, - opened_by_name: blockingSession.opened_by_name, - opened_at: blockingSession.opened_at, + id: sessionBlocking.id, + opened_by_name: sessionBlocking.opened_by_name, + opened_at: sessionBlocking.opened_at, } : null, }, }); } + const today = reportToday(); + const date = reportDate(req.query.date, today); + const [periodStart, periodEnd] = reportDayBounds(date); + + const aggregates = computeDayAggregates(db, date); + + const closedRow = db + .prepare( + `SELECT z_number FROM cash_closures WHERE business_date = ? AND scope = 'day' LIMIT 1`, + ) + .get(date) as { z_number: number } | undefined; + + // F3: most recent prior closed day for this business date, used by the + // modal to prefill the opening float (server-side one-shot query; the + // frontend no longer walks back day-by-day). + const priorRow = db + .prepare( + `SELECT business_date, counted_cash_cents FROM cash_closures + WHERE business_date < ? AND scope = 'day' + ORDER BY business_date DESC LIMIT 1`, + ) + .get(date) as { business_date: string; counted_cash_cents: number } | undefined; + + const blockingSessions = openSessionsBlockingDayClose(db, date); + const blockingSession = blockingSessions[0]; + const openShifts = blockingSessions.map((s) => ({ + id: s.id, + opened_by: s.opened_by, + opened_by_name: s.opened_by_name, + opened_at: s.opened_at, + })); + res.json({ xReport: { businessDate: date, diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index b80a80bba..26bc88b27 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -2901,6 +2901,26 @@ async function main() { closeA.body?.variance_cents === 0, '#1339: Cashier A closes Shift A with 9000c (200)', ); + const shiftAClosureRow = db + .prepare('SELECT z_number FROM cash_closures WHERE id = ?') + .get(closeA.body?.closure_id) as { z_number: number } | undefined; + const xShiftAClosed = await request(app) + .get(`/api/reports/x-report?date=2026-11-09&session_id=${shiftAId}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xShiftAClosedRep = xShiftAClosed.body?.xReport; + assert( + xShiftAClosed.status === 200 && + xShiftAClosedRep?.businessDate === '2026-11-10' && + xShiftAClosedRep?.alreadyClosed === true && + xShiftAClosedRep?.zNumber === shiftAClosureRow?.z_number && + xShiftAClosedRep?.session?.status === 'closed' && + Number(xShiftAClosedRep?.session?.closure_id) === Number(closeA.body?.closure_id) && + Array.isArray(xShiftAClosedRep?.openShifts) && + xShiftAClosedRep.openShifts.length === 1 && + Number(xShiftAClosedRep.openShifts[0]?.id) === shiftBId && + Number(xShiftAClosedRep?.blockingOpenShift?.id) === shiftBId, + `#1339: closed Shift A X report reports shift-scoped alreadyClosed=true, zNumber=${shiftAClosureRow?.z_number}, and shiftDate openShifts even when day is still open`, + ); // Day close is STILL blocked while Shift B remains open const dayBlockedOneLeft = await request(app) @@ -2966,6 +2986,17 @@ async function main() { dayZ?.refund_count === 1, `#1339: day Z report totals equal Shift A + Shift B (expected_cash=${dayZ?.expected_cash_cents}, net=${dayZ?.net_collected_cents})`, ); + const xShiftAAfterDayClose = await request(app) + .get(`/api/reports/x-report?date=2026-11-10&session_id=${shiftAId}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xShiftAAfterDayCloseRep = xShiftAAfterDayClose.body?.xReport; + assert( + xShiftAAfterDayClose.status === 200 && + xShiftAAfterDayCloseRep?.alreadyClosed === true && + xShiftAAfterDayCloseRep?.zNumber === shiftAClosureRow?.z_number && + xShiftAAfterDayCloseRep?.zNumber !== dayZ?.z_number, + `#1339: after day close, Shift A X report retains Shift A zNumber (${shiftAClosureRow?.z_number}) instead of day zNumber (${dayZ?.z_number})`, + ); // Turning one_shift_per_staff off now succeeds since <= 1 shift is open const disableAfterClose = await request(app) From d0224b90e37883a0805e88aed5fa358b2f7d75c0 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 14:06:43 -0400 Subject: [PATCH 19/22] no-mistakes(review): fix(cash): scope shift x-report closure metadata to session --- docs/reference/api.md | 2 +- main/routes/reports.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 0088feaed..03b9d7760 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -862,7 +862,7 @@ day-boundary rules. | `GET` | `/recentOrders` | `ROLE_ACCESS.ownerManager` | query: `?limit`, `?date`, `?start_date`, `?end_date` | - | | `GET` | `/tables` | `ROLE_ACCESS.ownerManager` | none | Table-level utilisation and non-cancelled order totals for the current business date; no date parameter. | | `GET` | `/insights` | `ROLE_ACCESS.ownerManager` | query: `?days` | - | -| `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date`, optional `?session_id` | Live day or shift report. Recomputed on every read. When `?session_id` is omitted, aggregates the full business date across all shifts and includes `openShifts` and `blockingOpenShift` (`session: null`). When `?session_id` is provided, scopes the X report (`grossCollected`, `refunded`, `netCollected`, `paymentMethods`, `cashMovements`, `openingFloatCents`, `expectedCashCents`, and `session`) to that shift (`400 session_id_invalid` for a non-positive integer; `404 session_unavailable` when not found). `openingFloatCents` is reported separately and excluded from `expectedCashCents`; the stored Z adds the opening float at close. | +| `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date`, optional `?session_id` | Live day or shift report. Recomputed on every read. When `?session_id` is omitted, aggregates the full business date across all shifts and includes `openShifts` and `blockingOpenShift` (`session: null`). When `?session_id` is provided, scopes the X report (`grossCollected`, `refunded`, `netCollected`, `paymentMethods`, `staffSales`, `taxComponents`, `cashMovements`, `openingFloatCents`, `expectedCashCents`, `alreadyClosed`, `zNumber`, `openShifts`, `blockingOpenShift`, and `session`) to that shift and its business date (`400 session_id_invalid` for a non-positive integer; `404 session_unavailable` when not found; `403 shift_access_forbidden` when a non-owner/non-manager requests another staff member's shift). `openingFloatCents` is reported separately and excluded from `expectedCashCents`; the stored Z adds the opening float at close. | | `GET` | `/z-report` | `ROLE_ACCESS.ownerManager` | query: `?date` | The immutable close snapshot for one business date. `404` with `{ error: "Day not closed", alreadyClosed: false, businessDate }` when no close row exists. The `zReport` object carries integer-minor-unit `*_cents` fields, `payment_methods`, `staff_sales`, `tax_components`, and `cash_movements` arrays, plus `z_number`, `closed_by`, `closed_by_name`, `notes`, `created_at`, `business_date`, `period_start`, and `period_end`. `variance_cents` is `counted_cash_cents - expected_cash_cents` and may be negative. | | `GET` | `/x-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv` or `xlsx`) | Exports a live X-report snapshot for one business date. Returns `400` with `{ error: "format must be xlsx or csv" }` when `format` is missing or invalid. Sets `Content-Disposition` to `attachment; filename="x-report-."`. | | `GET` | `/z-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv` or `xlsx`) | Exports the stored Z-report snapshot for a closed business date. Returns `400` with `{ error: "format must be xlsx or csv" }` when `format` is missing or invalid, and `404` with `{ error: "Day not closed" }` when the day has not been closed. Sets `Content-Disposition` to `attachment; filename="z-report-Z-."`. | diff --git a/main/routes/reports.ts b/main/routes/reports.ts index 12c3fe324..c65c27893 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -1219,9 +1219,12 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R if (!Number.isInteger(sessionId) || sessionId <= 0) { return res.status(400).json({ error: 'Invalid session_id', code: 'session_id_invalid' }); } - const session = db.prepare(`SELECT * FROM cash_sessions WHERE id = ?`).get(sessionId) as - | CashSessionRow - | undefined; + const session = db + .prepare( + `SELECT id, opened_by, opened_by_name, opened_at, opening_float_cents, status, closed_at, closed_by, closure_id + FROM cash_sessions WHERE id = ?`, + ) + .get(sessionId) as CashSessionRow | undefined; if (!session) { return res.status(404).json({ error: 'Shift not found', code: 'session_unavailable' }); } From 94036851987bf540431dc5d2f28ed342ac8942a5 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 14:33:27 -0400 Subject: [PATCH 20/22] no-mistakes(document): Update shift and PIN lockout docs and fix lint warnings --- .../authentication-and-authorization.md | 7 ++++--- docs/reference/api.md | 12 +++++------- docs/reference/roles-and-permissions.md | 12 +++++++----- main/routes/cash-closures.ts | 16 ++++++++++------ main/routes/cash-sessions.ts | 1 - main/routes/reports.ts | 1 - 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/architecture/authentication-and-authorization.md b/docs/architecture/authentication-and-authorization.md index 5c54232b4..2fc4d6d0f 100644 --- a/docs/architecture/authentication-and-authorization.md +++ b/docs/architecture/authentication-and-authorization.md @@ -203,9 +203,10 @@ terminals, while approval authority stays restricted to active `owner` and `mana 2. **Selected-user PIN sign-in (`POST /api/auth/pin-login`):** Accepts `{ user_id, pin, rememberMe }` and verifies the PIN strictly against the selected `user_id` via `verifyPinAsync` without scanning other users, so two staff members may share the same PIN. Verification and rate-limit - checks are serialized per user key (`${normalizedIp}:pin:${userId}`) with - `withPinVerificationLock`, failed attempts are tracked by the install-wide login rate limiter per - user, and every attempt is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in + checks are serialized per target account (`pin-user:${userId}`) with `withPinVerificationLock`, + failed attempts are tracked by the install-wide login rate limiter both per client IP + user + (`${normalizedIp}:pin:${userId}`) and per target account across source IPs (`pin-user:${userId}`), + and every attempt is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in `'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 diff --git a/docs/reference/api.md b/docs/reference/api.md index 03b9d7760..09bb665d1 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -108,11 +108,9 @@ LAN clients are counted against authentication rate limits. `loginAuthRateLimit` is the shared volume limiter across all three default login endpoints (`POST /api/auth/login` on `3001`, `3002`, and `3003`). It reuses a single shared budget (default 10 requests per 15 minutes, configurable via `FLO_AUTH_RATE_LIMIT_MAX`). -In addition, the three password login endpoints share an install-wide failed-login attempt lockout keyed to -canonical client IP (`normalizeIpForRateLimit`), and `POST /api/auth/pin-login` keys its failed-login lockout per user (`${normalizedIp}:pin:${user_id}`): 5 failed credential attempts trigger a 15-minute lockout (`429`). Successful login -resets the counter for that key (non-credential `403` responses preserve accumulated failures). See +In addition, the three password login endpoints share an install-wide 5-attempt, 15-minute failed-login lockout (`429`) per canonical client IP, and `POST /api/auth/pin-login` enforces the same lockout per target `user_id` (both per client IP and across source IPs). See [`docs/architecture/authentication-and-authorization.md`](../architecture/authentication-and-authorization.md#shared-login-attempt-rate-limiting-across-servers) -for architecture and role/permission handling details. +for lockout and PIN rate-limiting details. `authRateLimit` provides independent per-route volume limiters (also defaulting to 10 attempts per 15 minutes, overridable with `FLO_AUTH_RATE_LIMIT_MAX`) for non-login credential and staff @@ -158,7 +156,7 @@ Router: `main/routes/auth.ts`. Full path: `/api/auth`. | ------ | -------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST` | `/login` | none, `loginAuthRateLimit` + shared failed-login limiter | body: `email`, `password`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenants: [ ... ] }`. `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts across local login listeners. | | `GET` | `/pin-users` | none, auth limiter (600 per window) | none | `{ users: [ { id, name, role } ] }` for active users with a PIN configured. | -| `POST` | `/pin-login` | none, auth limiter (600 failed per window) + per-user failed-login limiter | body: `user_id`, `pin`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenant, tenants: [ ... ] }`. `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts for that IP and `user_id`. | +| `POST` | `/pin-login` | none, auth limiter (600 failed per window) + per-user failed-login limiter | body: `user_id`, `pin`, `rememberMe` | `{ access_token, token_type: "bearer", expires_in, user: { id, name, email, role, category_ids }, tenant, tenants: [ ... ] }`. `400` when `user_id` or `pin` is missing; `401` on bad credentials with `attempts_remaining`; `429` after 5 failed attempts for that `user_id` (per IP or across source IPs). | | `POST` | `/tenants/select` | any authenticated role | none | `{ access_token, token_type: "bearer", tenant }`. | | `POST` | `/logout` | any authenticated role | none | `{ message }`. Revokes the presented token. | | `POST` | `/refresh` | any authenticated role | none | `{ access_token, token_type: "bearer", expires_in }`. | @@ -405,7 +403,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` | `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). Requires 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 (`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` | `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). Requires 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 was recorded outside an open cash shift or its cash shift is closed (`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 outside an open shift or 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` 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` when the order is cancelled. | | `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. | @@ -504,7 +502,7 @@ Router: `main/routes/cash-closures.ts`. Full path: `/api/cash-closures`. | Method | Path | Authorization | Parameters | Response | | ------ | --------------------- | --------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `GET` | `/movements` | `ROLE_ACCESS.ownerManagerCashier` | query: `?business_date` | Append-only cash-drawer history, newest first, including soft-voided rows. | -| `POST` | `/movements` | `ROLE_ACCESS.ownerManagerCashier` | body: `business_date`, `movement_type`, `amount_cents`, `reason`, optional `cash_session_id` | Attaches the movement to the caller's open shift, or to `cash_session_id` when specified (`400` if not a positive integer, `404 cash_session_not_found` if not an open shift, `403 session_owner_required` if a non-`owner`/`manager` targets another user's shift). | +| `POST` | `/movements` | `ROLE_ACCESS.ownerManagerCashier` | body: `business_date`, `movement_type`, `amount_cents`, `reason`, optional `cash_session_id` | Attaches the movement to the caller's open shift, or to `cash_session_id` when specified (`400` if not a positive integer, `404 cash_session_not_found` if not found, `409 session_already_closed` if already closed, `403 session_owner_required` if a non-`owner`/`manager` targets another user's shift). | | `POST` | `/movements/:id/void` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `reason` | - | | `POST` | `/` | `ROLE_ACCESS.owner` | body: `business_date`, `opening_float_cents`, `counted_cash_cents`, `notes` | Closes a business date. `counted_cash_cents` is the physical count; the response carries the variance and the next `z_number`. `409` with `open_shift_blocks_day_close` until all shifts opened on or before that business date are closed (response includes `blocking_shift` and `blocking_shifts`). | | `POST` | `/:id/print` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `isReprint` | Prints the close receipt. | diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index 1916c06eb..0629eaaf4 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -156,11 +156,13 @@ every save. **Cash shifts.** Opening, viewing, and closing shifts are gated by `cash.shifts.open`, `cash.shifts.view`, and `cash.shifts.close`. Non-`owner`/`manager` roles (such as `cashier`) may -only view, preview, close, or attach explicit drawer movements to the shift they opened (`403 -session_owner_required` when targeting another user's shift), whereas an `owner` or `manager` can -inspect and close any staff member's open shift, with the closing actor recorded on the closure row. -On `POST /api/cash-sessions/:id/close`, the own-session rule is enforced inside the close -transaction, where the stored session and the actor are read together. This is a context policy, not +only view, preview, close, attach explicit drawer movements to, or read the shift X-report for the +shift they opened (`403 session_owner_required` on shift/movement routes and `403 +shift_access_forbidden` on `GET /api/reports/x-report?session_id=` when targeting another user's +shift), whereas an `owner` or `manager` can inspect and close any staff member's shift, with the +closing actor recorded on the closure row. On `POST /api/cash-sessions/:id/close` and +`POST /api/cash-closures/movements`, the own-session rule is enforced inside the write transaction, +where the stored session and the actor are read together. This is a context policy, not a permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md) and [business time](../architecture/business-time.md#cash-closures). diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index fa3e7740f..2f2e4180e 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -44,10 +44,7 @@ import { import { requirePermission } from '../services/authorization'; import { nextZNumber } from '../db'; import { getTenantCurrency } from '../services/refund'; -import { - openSessionBlockingDayClose, - openSessionsBlockingDayClose, -} from '../services/cash-day-close-guards'; +import { openSessionsBlockingDayClose } from '../services/cash-day-close-guards'; import { getOpenSession, NO_CASH_SESSION_ID, @@ -921,6 +918,13 @@ export function computePeriodAggregates( // rest of the Z. Bills are hydrated with their order items and then // aggregated via the existing `aggregateTaxComponents` pipeline, unchanged. // Unpaid bills drop out by the same logic as the staff query above. + type TaxBillRow = { + id: number; + order_id: number; + tax_amount: number | null; + tax_snapshot: string | null; + tax_breakdown: string | null; + }; const bills = session ? (db .prepare( @@ -955,7 +959,7 @@ export function computePeriodAggregates( ORDER BY b.paid_at, b.id `, ) - .all(session.id, session.opened_at, end) as any[]) + .all(session.id, session.opened_at, end) as TaxBillRow[]) : (db .prepare( ` @@ -965,7 +969,7 @@ export function computePeriodAggregates( ORDER BY b.paid_at, b.id `, ) - .all(start, end) as any[]); + .all(start, end) as TaxBillRow[]); const orders = getOrdersWithItemsForBills(db, bills); const taxDocuments = bills.map((bill) => ({ tax_amount: bill.tax_amount, diff --git a/main/routes/cash-sessions.ts b/main/routes/cash-sessions.ts index 554b1f1fd..808a32c92 100644 --- a/main/routes/cash-sessions.ts +++ b/main/routes/cash-sessions.ts @@ -133,7 +133,6 @@ function closeSessionTxn( Number(session.opening_float_cents || 0), session, ), - // Drawer and tender totals follow ownership; staff/tax remain settlement-based. ...sessionFinancialTotals(db, session, closedAt), }; const expectedCashCents = sessionExpectedCash(db, session, closedAt, sessionMovements); diff --git a/main/routes/reports.ts b/main/routes/reports.ts index c65c27893..29215b8ce 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -25,7 +25,6 @@ import { aggregateTaxComponents } from '../services/tax-components'; import { getTenantCurrency } from '../services/refund'; import { getCurrencyMinorUnitFactor, resolveRegionalSnapshot } from '../countries'; import { - openSessionBlockingDayClose, openSessionsBlockingDayClose, sessionBusinessDate, } from '../services/cash-day-close-guards'; From fe3eb5dceaecc56213db23cf7b3fbc236fd6ff35 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Wed, 28 Oct 2026 16:51:19 -0400 Subject: [PATCH 21/22] fix(billing): restrict payment method correction to owner/manager and add v3.14.0 combined acceptance test --- docs/reference/api.md | 2 +- docs/reference/roles-and-permissions.md | 14 +- frontend/src/app/(dashboard)/orders/page.tsx | 1 + .../components/orders/OrderDetailPanel.tsx | 17 +- main/routes/bills.ts | 206 ++++--- tests/cash-sessions.test.ts | 336 +++++++++++ tests/payment-method-correction.test.ts | 567 ++++++++++++++++-- 7 files changed, 986 insertions(+), 157 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 09bb665d1..43fe2b6e7 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -403,7 +403,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` | `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). Requires 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 was recorded outside an open cash shift or its cash shift is closed (`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 outside an open shift or 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` (`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 outside an open shift or 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` 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` when the order is cancelled. | | `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. | diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index 0629eaaf4..0b3a19e41 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -177,12 +177,14 @@ manager PIN (with `payments.take` when overriding unsettled payment), even when is already an owner or manager. Full safeguard, restock, and audit rules are in [the Orders API reference](api.md#orders). -**Paid bill payment method correction.** Staff holding `payments.take` can correct the tender method -on a recorded payment line of a `paid` bill (`PATCH /api/bills/:id/payments/:index/method`) while -its cash shift and store business day remain open, and every correction requires an active `owner` -or `manager` Staff Approval PIN (`override_pin`, `approver_id`); a cashier's own PIN never -authorizes this action (`403`). Full shift, refund, split-sibling, amount-immutability, and audit -rules are in [the Bills API reference](api.md#bills). +**Paid bill payment method correction.** Only an active `owner` or `manager` holding `payments.take` +can correct the tender method on a recorded payment line of a `paid` bill +(`PATCH /api/bills/:id/payments/:index/method`) while its cash shift and store business day remain +open (`Yes (with Staff Approval PIN)` for Owner and Manager; `No` for Cashier, Server, and Chef). +Every correction requires an active `owner` or `manager` Staff Approval PIN (`override_pin`, +`approver_id`); a `cashier`, `server`, or `chef` caller or PIN never authorizes this action (`403`). +Full shift, refund, split-sibling, amount-immutability, and audit rules are in +[the Bills API reference](api.md#bills). **Owner visibility.** The permission editor itself is rendered only for an authenticated user holding `authorization.manage` — an active owner, always. The API enforces this independently; diff --git a/frontend/src/app/(dashboard)/orders/page.tsx b/frontend/src/app/(dashboard)/orders/page.tsx index 9b28563de..9de8061b7 100644 --- a/frontend/src/app/(dashboard)/orders/page.tsx +++ b/frontend/src/app/(dashboard)/orders/page.tsx @@ -1882,6 +1882,7 @@ export default function OrdersPage() { onAddItems={openAddItemsModal} onRefund={(ord, bills) => setRefundModal({ order: ord, bills })} onCorrectPaymentMethod={ + (currentTenant?.role === 'owner' || currentTenant?.role === 'manager') && tenantCan(currentTenant, 'payments.take') ? (ord, bills) => setCorrectPaymentModal({ order: ord, bills }) : undefined diff --git a/frontend/src/components/orders/OrderDetailPanel.tsx b/frontend/src/components/orders/OrderDetailPanel.tsx index eb795dcca..55a3e94cd 100644 --- a/frontend/src/components/orders/OrderDetailPanel.tsx +++ b/frontend/src/components/orders/OrderDetailPanel.tsx @@ -47,8 +47,10 @@ import { Ltr } from '@/components/layout/Ltr'; import { useFormatCurrency } from '@/hooks/useFormatCurrency'; import { useFormatDate } from '@/hooks/useFormatDate'; import { parseDbTimestamp } from '@/lib/utils'; +import { useAuthStore } from '@/store/auth'; import { formatItemHeading } from '@/lib/printer/item-heading'; import { isTerminalItemStatus } from '../../../../shared/order-item-status'; +import { ROLE_ACCESS, hasRole } from '../../../../shared/role-permissions'; import PendingOrderItemEdits from './PendingOrderItemEdits'; import { orderStatusBadge, @@ -213,6 +215,13 @@ function OrderDetailContent({ const paidBills = orderBills.filter( (b) => Number(b.paid_amount) > 0 && b.payment_status !== 'refunded', ); + const storeRole = useAuthStore( + (s) => s.currentTenant?.role ?? (s.user as { role?: string } | null)?.role, + ); + const authRole = + storeRole ?? + useAuthStore.getState?.()?.currentTenant?.role ?? + (useAuthStore.getState?.()?.user as { role?: string } | null)?.role; const correctablePaidBills = orderBills.filter( (b) => b.payment_status === 'paid' && @@ -220,6 +229,10 @@ function OrderDetailContent({ (line) => String(line.method || '').toLowerCase() !== 'wallet', ), ); + const canCorrectPaymentMethod = + Boolean(onCorrectPaymentMethod) && + hasRole(authRole, ROLE_ACCESS.ownerManager) && + correctablePaidBills.length > 0; const hasEligibleRefund = paidBills.length > 0; const hasCollectedFunds = orderBills.some((candidate) => { const status = String(candidate.payment_status || ''); @@ -381,7 +394,7 @@ function OrderDetailContent({ {tOrders('downloadPrintPreview')} )} - {onCorrectPaymentMethod && correctablePaidBills.length > 0 && ( + {canCorrectPaymentMethod && onCorrectPaymentMethod && ( onCorrectPaymentMethod(order, correctablePaidBills)} @@ -949,7 +962,7 @@ function OrderDetailContent({ {tOrders('sendReceipt')} )} - {onCorrectPaymentMethod && correctablePaidBills.length > 0 && ( + {canCorrectPaymentMethod && onCorrectPaymentMethod && (