diff --git a/docs/architecture/business-time.md b/docs/architecture/business-time.md index 90cfd8e8d..cd0d222ef 100644 --- a/docs/architecture/business-time.md +++ b/docs/architecture/business-time.md @@ -124,6 +124,17 @@ business-day cutoff therefore stays filed under the business date on which it op reporting and day-close aggregates continue to bound sales by each business day's `[start, end)` window. +When the opt-in setting `one_shift_per_staff` is `'false'` (the default), at most one shift may be +open across the store at a time. When `one_shift_per_staff` is `'true'`, each staff member can open +and close at most one shift of their own (`cash_sessions_one_open_per_user` on `(opened_by) WHERE status = 'open'`). +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 (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 `main/services/refund.ts` allows a refund freely within `REFUND_WINDOW_MS` of order creation, which 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 97b7c3e25..f755bf777 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,12 +487,15 @@ Router: `main/routes/payment-methods.ts`. Full path: `/api/payment-methods`. Router: `main/routes/cash-sessions.ts`. Full path: `/api/cash-sessions`. +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` when a shift is already open (`session_already_open`) or the current store business day is already closed (`day_already_closed`). | -| `GET` | `/current` | `ROLE_ACCESS.ownerManagerCashier` | none | The open shift, or `null` when none is open. | -| `GET` | `/current/close-preview` | `ROLE_ACCESS.ownerManagerCashier` | none | Informational carry-over preview of open/unpaid bills created during the current shift window (`unpaid_in_shift` with `count`, `bill_count`, `pending_cents`, `sample`, and `would_block_close: false`). Does not close or block closing the 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 cashier attempts to close another user's shift (`session_owner_required`). | +| `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 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 @@ -501,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` | - | +| `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` when a shift opened on or before that business date is still open (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 @@ -551,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. | @@ -653,7 +656,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/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. | | `GET` | `/:key` | `ROLE_ACCESS.allStaff` + settings read limiter | path: `key` | One setting. Registered after every named route, so a named key resolves to its named handler. | -| `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort`, `pos_item_sort`, and `auto_lock_minutes` in `0`, `1`, `5`, `15`, or `30`); sensitive keys need their named route. `key: currency` returns `409 currency_change_requires_reset` on any value change. | +| `PUT` | `/:key` | `ROLE_ACCESS.ownerManager` + settings write limiter | path: `key`; body: `value` | Body `value`. Only the allow-listed non-sensitive keys are accepted (including `pos_category_sort`, `pos_item_sort`, `require_open_shift`, `one_shift_per_staff`, and `auto_lock_minutes` in `0`, `1`, `5`, `15`, or `30`); sensitive keys need their named route. `key: one_shift_per_staff` with `value: false` returns `409 multiple_shifts_open` while more than one shift is open. `key: currency` returns `409 currency_change_requires_reset` on any value change. | `diagnostics_consent` and `diagnostics_transmission_enabled` are owner/manager-writable Boolean settings. Their delivery gates and consent synchronization are described in [store-attributed @@ -859,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`, `?start_date`, `?end_date` | - | -| `GET` | `/x-report` | `ROLE_ACCESS.ownerManager` | query: `?date` | Live day report. Recomputed on every read. `openingFloatCents` is reported for context and is excluded from `expectedCashCents`, which includes cash sales, Pay In, Pay Out, Safe Drop, and cash refunds by `refunds.created_at`. The stored Z adds the opening float at close, so same-day X and Z expected values differ by exactly `opening_float_cents`. | +| `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`, `xlsx`, or `html`) | Exports a live X-report snapshot for one business date (or print-styled HTML for PDF/browser print when `format=html`). Returns `400` with `{ error: "format must be xlsx, csv, or html" }` 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`, `xlsx`, or `html`) | Exports the stored Z-report snapshot for a closed business date (or print-styled HTML for PDF/browser print when `format=html`). Returns `400` with `{ error: "format must be xlsx, csv, or html" }` 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 59d1d4545..0b3a19e41 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -154,11 +154,17 @@ 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, 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). **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 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/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index 2331f759d..9d2471b02 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -2641,6 +2641,19 @@ export default function POSPage() { if (result.session) shift.setCloseModalOpen(true); else shift.setOpenModalOpen(true); }} + onShowCloseStaffShift={ + !shift.session && shift.openSessions.length > 0 + ? async () => { + const result = await shift.refresh(); + if (result.status !== 'ok') return; + if (result.error) { + toast.error(result.error || shift.shiftLoadFailedMessage); + return; + } + if (result.openSessions.length > 0) shift.setCloseModalOpen(true); + } + : undefined + } shiftHasOpenSession={!!shift.session} shiftLoading={shift.loading} shiftError={shift.error} diff --git a/frontend/src/components/dashboard/CashCloseModal.tsx b/frontend/src/components/dashboard/CashCloseModal.tsx index 73cfae3df..ce3b0bd8c 100644 --- a/frontend/src/components/dashboard/CashCloseModal.tsx +++ b/frontend/src/components/dashboard/CashCloseModal.tsx @@ -48,6 +48,8 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) { closeAnotherDay, businessDate, setBusinessDate, + selectedSessionId, + setSelectedSessionId, xReport, xLoading, xError, @@ -239,6 +241,7 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) { const next = e.target.value; if (!/^\d{4}-\d{2}-\d{2}$/.test(next)) return; if (next > todayLocal) return; + setSelectedSessionId(null); setBusinessDate(next); }} className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background text-foreground outline-none focus:ring-2 focus:ring-brand/30" @@ -251,6 +254,32 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) { {xReport && ( <> + {(xReport.openShifts?.length ?? 0) > 0 && ( +
+ + +
+ )}
{exportMenu('x')}
{(xReport.alreadyClosed || alreadyClosedOverride) && (
@@ -273,6 +302,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..e5f82a139 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, @@ -38,7 +42,9 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { printClosure, setSubmitError, closePreview, + setClosePreview, } = model; + const targetSession = activeSession ?? session; const tPos = useTranslations('pos'); const unpaidInShiftCount = closePreview?.unpaid_in_shift.count ?? closePreview?.unpaid_in_shift.bill_count ?? 0; @@ -54,6 +60,8 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) { setClosedResult(null); setSubmitError(null); setCountedInput(''); + setSelectedSessionId(null); + setClosePreview(null); setHasPrinted(false); } setCloseModalOpen(next); @@ -78,8 +86,37 @@ 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 +238,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 5f7a8494b..1b63d1515 100644 --- a/frontend/src/hooks/useCashClose.ts +++ b/frontend/src/hooks/useCashClose.ts @@ -63,6 +63,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; @@ -160,6 +176,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 @@ -167,7 +184,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(''); @@ -220,7 +237,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); @@ -313,7 +336,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 @@ -330,6 +353,7 @@ export function useCashClose() { setAlreadyClosedOverride(false); setBusinessDate(dateOverride ?? todayLocal); setDateOverride(null); + setSelectedSessionId(null); setOpeningFloatInput(''); setCountedInput(''); setSubmitError(null); @@ -646,6 +670,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..86c7ad45d 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); @@ -87,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 ? String(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 @@ -94,19 +112,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 +148,51 @@ 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); + setSelectedSessionId(null); + setClosePreview(null); 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 +202,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 +236,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,11 +245,13 @@ 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); setCountedInput(''); + setSelectedSessionId(null); + setClosePreview(null); toast.success(t('shiftClosed')); if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('flo-shift-changed')); await refresh(); @@ -249,12 +298,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 ab64d5e66..d592a6c2b 100644 --- a/frontend/src/lib/i18n/messages/ar.json +++ b/frontend/src/lib/i18n/messages/ar.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {سيتم ترحيل طلب مفتوح واحد (#)} other {سيتم ترحيل # طلبات مفتوحة}}", "shiftCloseUnpaidWarningHint": "تظل هذه الطلبات مفتوحة للوردية التالية. إغلاق هذه الوردية يطابق فقط المدفوعات النقدية التي تم تحصيلها بالفعل خلال هذه الوردية.", "shiftCloseUnpaidMoreCount": "+{count} طلبات مفتوحة أخرى", - "lockTerminal": "قفل نقطة البيع" + "lockTerminal": "قفل نقطة البيع", + "selectOpenShift": "الوردية المراد إغلاقها", + "xReportShiftFilterLabel": "نطاق الوردية", + "xReportAllShifts": "جميع الورديات (اليوم بالكامل)" }, "printTest": { "amt": "المبلغ", @@ -2571,7 +2574,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 2e1fd7014..093bc073d 100644 --- a/frontend/src/lib/i18n/messages/bn.json +++ b/frontend/src/lib/i18n/messages/bn.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {#টি খোলা অর্ডার পরবর্তী শিফটে যাবে} other {#টি খোলা অর্ডার পরবর্তী শিফটে যাবে}}", "shiftCloseUnpaidWarningHint": "এই অর্ডারগুলো পরবর্তী শিফটের জন্য খোলা থাকবে। এই শিফট বন্ধ করলে শুধুমাত্র এই শিফটে ইতিমধ্যে সংগৃহীত নগদ পেমেন্টের হিসাব মেলানো হয়।", "shiftCloseUnpaidMoreCount": "+আরও {count}টি খোলা অর্ডার", - "lockTerminal": "টার্মিনাল লক করুন" + "lockTerminal": "টার্মিনাল লক করুন", + "selectOpenShift": "বন্ধ করার শিফট", + "xReportShiftFilterLabel": "শিফটের পরিধি", + "xReportAllShifts": "সব শিফট (সারাদিন)" }, "printTest": { "amt": "এএমটি", @@ -2571,7 +2574,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 cf1e3e65a..fb21f0547 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 d753f8b22..eee757908 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 d63fb6d67..3325e7145 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 834292136..80a56532c 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# سفارش باز به شیفت بعد منتقل می‌شود} other {# سفارش باز به شیفت بعد منتقل می‌شوند}}", "shiftCloseUnpaidWarningHint": "این سفارش‌ها برای شیفت بعدی باز می‌مانند. بستن این شیفت فقط پرداخت‌های نقدی دریافت‌شده در همین شیفت را تسویه می‌کند.", "shiftCloseUnpaidMoreCount": "+{count} سفارش باز دیگر", - "lockTerminal": "قفل پایانه" + "lockTerminal": "قفل پایانه", + "selectOpenShift": "شیفت مورد نظر برای بستن", + "xReportShiftFilterLabel": "محدوده شیفت", + "xReportAllShifts": "همه شیفت‌ها (کل روز)" }, "printTest": { "amt": "مبلغ", @@ -2571,7 +2574,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 2fea6d9e1..28b0913e4 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 0da24b424..38bdc3de1 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 9b3ef0099..59c769f96 100644 --- a/frontend/src/lib/i18n/messages/hi.json +++ b/frontend/src/lib/i18n/messages/hi.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# खुला ऑर्डर अगली शिफ्ट में आगे जाएगा} other {# खुले ऑर्डर अगली शिफ्ट में आगे जाएंगे}}", "shiftCloseUnpaidWarningHint": "ये ऑर्डर अगली शिफ्ट के लिए खुले रहते हैं। इस शिफ्ट को बंद करने पर केवल इस शिफ्ट के दौरान पहले से प्राप्त नकद भुगतानों का मिलान होता है।", "shiftCloseUnpaidMoreCount": "+{count} और खुले ऑर्डर", - "lockTerminal": "टर्मिनल लॉक करें" + "lockTerminal": "टर्मिनल लॉक करें", + "selectOpenShift": "बंद करने के लिए शिफ्ट", + "xReportShiftFilterLabel": "शिफ्ट का दायरा", + "xReportAllShifts": "सभी शिफ्ट (पूरा दिन)" }, "printTest": { "amt": "राशि", @@ -2571,7 +2574,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 a5726a4b9..7360835be 100644 --- a/frontend/src/lib/i18n/messages/id.json +++ b/frontend/src/lib/i18n/messages/id.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 46fdbcf0e..6718362bd 100644 --- a/frontend/src/lib/i18n/messages/it.json +++ b/frontend/src/lib/i18n/messages/it.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 6f30b8d7e..c53391fa4 100644 --- a/frontend/src/lib/i18n/messages/ja.json +++ b/frontend/src/lib/i18n/messages/ja.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# 件の未会計注文が次のシフトに引き継がれます} other {# 件の未会計注文が次のシフトに引き継がれます}}", "shiftCloseUnpaidWarningHint": "これらの注文は次のシフトに引き継がれます。このシフトの精算では、このシフト中に受け取った現金支払いのみを集計します。", "shiftCloseUnpaidMoreCount": "他 +{count} 件の未会計注文", - "lockTerminal": "端末をロック" + "lockTerminal": "端末をロック", + "selectOpenShift": "閉じるシフト", + "xReportShiftFilterLabel": "シフト範囲", + "xReportAllShifts": "すべてのシフト(終日)" }, "printTest": { "amt": "金額", @@ -2571,7 +2574,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 1f8a059af..3692f8960 100644 --- a/frontend/src/lib/i18n/messages/ko.json +++ b/frontend/src/lib/i18n/messages/ko.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {미결제 주문 #건이 다음 교대근무로 이월됩니다} other {미결제 주문 #건이 다음 교대근무로 이월됩니다}}", "shiftCloseUnpaidWarningHint": "이 주문들은 다음 교대근무를 위해 열린 상태로 유지됩니다. 이번 교대근무 마감은 이번 교대근무 중에 이미 수납된 현금 결제만 정산합니다.", "shiftCloseUnpaidMoreCount": "+{count}건의 미결제 주문 더보기", - "lockTerminal": "단말기 잠금" + "lockTerminal": "단말기 잠금", + "selectOpenShift": "닫을 교대조", + "xReportShiftFilterLabel": "교대조 범위", + "xReportAllShifts": "모든 교대조 (종일)" }, "printTest": { "amt": "금액", @@ -2571,7 +2574,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 ab7cf379a..5134891cb 100644 --- a/frontend/src/lib/i18n/messages/ne.json +++ b/frontend/src/lib/i18n/messages/ne.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# खुला अर्डर अर्को सिफ्टमा सर्नेछ} other {# खुला अर्डरहरू अर्को सिफ्टमा सर्नेछन्}}", "shiftCloseUnpaidWarningHint": "यी अर्डरहरू अर्को सिफ्टका लागि खुला नै रहन्छन्। यो सिफ्ट बन्द गर्दा यस सिफ्टमा पहिले नै संकलन गरिएका नगद भुक्तानी मात्र मिलान गरिन्छ।", "shiftCloseUnpaidMoreCount": "+थप {count} खुला अर्डरहरू", - "lockTerminal": "टर्मिनल लक गर्नुहोस्" + "lockTerminal": "टर्मिनल लक गर्नुहोस्", + "selectOpenShift": "बन्द गर्ने सिफ्ट", + "xReportShiftFilterLabel": "सिफ्टको दायरा", + "xReportAllShifts": "सबै सिफ्टहरू (पूरा दिन)" }, "printTest": { "amt": "रकम", @@ -2571,7 +2574,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 234c3d7bc..3b4a18af5 100644 --- a/frontend/src/lib/i18n/messages/nl.json +++ b/frontend/src/lib/i18n/messages/nl.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 026ecd375..31085d825 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 44f2420d0..32d48a48a 100644 --- a/frontend/src/lib/i18n/messages/ru.json +++ b/frontend/src/lib/i18n/messages/ru.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# открытый заказ перейдет в следующую смену} other {# открытых заказов перейдут в следующую смену}}", "shiftCloseUnpaidWarningHint": "Эти заказы остаются открытыми для следующей смены. Закрытие этой смены сверяет только наличные платежи, уже принятые в течение этой смены.", "shiftCloseUnpaidMoreCount": "+ещё {count} открытых заказов", - "lockTerminal": "Заблокировать терминал" + "lockTerminal": "Заблокировать терминал", + "selectOpenShift": "Смена для закрытия", + "xReportShiftFilterLabel": "Охват смены", + "xReportAllShifts": "Все смены (весь день)" }, "printTest": { "amt": "Сумма", @@ -2571,7 +2574,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 786c8ac9b..221918e0e 100644 --- a/frontend/src/lib/i18n/messages/sq.json +++ b/frontend/src/lib/i18n/messages/sq.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 1306b4144..1232da0b4 100644 --- a/frontend/src/lib/i18n/messages/th.json +++ b/frontend/src/lib/i18n/messages/th.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {ออเดอร์ที่เปิดอยู่ # รายการจะส่งต่อไปกะถัดไป} other {ออเดอร์ที่เปิดอยู่ # รายการจะส่งต่อไปกะถัดไป}}", "shiftCloseUnpaidWarningHint": "ออเดอร์เหล่านี้จะยังคงเปิดอยู่สำหรับกะถัดไป การปิดกะนี้จะกระทบยอดเฉพาะเงินสดที่รับชำระแล้วในระหว่างกะนี้เท่านั้น", "shiftCloseUnpaidMoreCount": "+อีก {count} ออเดอร์ที่เปิดอยู่", - "lockTerminal": "ล็อกเครื่อง POS" + "lockTerminal": "ล็อกเครื่อง POS", + "selectOpenShift": "กะที่จะปิด", + "xReportShiftFilterLabel": "ขอบเขตกะ", + "xReportAllShifts": "ทุกกะ (ทั้งวัน)" }, "printTest": { "amt": "จำนวนเงิน", @@ -2571,7 +2574,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 14ff0d405..e485b3b8e 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 0e88b55fb..ec46884c6 100644 --- a/frontend/src/lib/i18n/messages/ur.json +++ b/frontend/src/lib/i18n/messages/ur.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# کھلا آرڈر اگلی شفٹ میں منتقل ہوگا} other {# کھلے آرڈرز اگلی شفٹ میں منتقل ہوں گے}}", "shiftCloseUnpaidWarningHint": "یہ آرڈرز اگلی شفٹ کے لیے کھلے رہیں گے۔ اس شفٹ کو بند کرنے سے صرف وہ نقد ادائیگیاں ملائی جاتی ہیں جو اس شفٹ کے دوران وصول ہو چکی ہیں۔", "shiftCloseUnpaidMoreCount": "+{count} مزید کھلے آرڈرز", - "lockTerminal": "ٹرمینل لاک کریں" + "lockTerminal": "ٹرمینل لاک کریں", + "selectOpenShift": "بند کرنے کے لیے شفٹ", + "xReportShiftFilterLabel": "شفٹ کا دائرہ", + "xReportAllShifts": "تمام شفٹیں (پورا دن)" }, "printTest": { "amt": "رقم", @@ -2571,7 +2574,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 b54ffd2d7..1e6822835 100644 --- a/frontend/src/lib/i18n/messages/vi.json +++ b/frontend/src/lib/i18n/messages/vi.json @@ -1211,7 +1211,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", @@ -2571,7 +2574,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 78e268900..25ba5259f 100644 --- a/frontend/src/lib/i18n/messages/zh-tw.json +++ b/frontend/src/lib/i18n/messages/zh-tw.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# 筆未結訂單將結轉至下一班次} other {# 筆未結訂單將結轉至下一班次}}", "shiftCloseUnpaidWarningHint": "這些訂單將保持開啟並交接給下一班次。關閉本班次僅核對本班次期間已收取的現金付款。", "shiftCloseUnpaidMoreCount": "另有 +{count} 筆未結訂單", - "lockTerminal": "鎖定終端機" + "lockTerminal": "鎖定終端機", + "selectOpenShift": "要關閉的班次", + "xReportShiftFilterLabel": "班次範圍", + "xReportAllShifts": "所有班次(全天)" }, "printTest": { "amt": "金額", @@ -2571,7 +2574,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 b455984fe..30e133f8d 100644 --- a/frontend/src/lib/i18n/messages/zh.json +++ b/frontend/src/lib/i18n/messages/zh.json @@ -1211,7 +1211,10 @@ "shiftCloseUnpaidWarningTitle": "{count, plural, one {# 笔未结订单将结转至下一班次} other {# 笔未结订单将结转至下一班次}}", "shiftCloseUnpaidWarningHint": "这些订单将保持开启并交接给下一班次。关闭本班次仅核对本班次期间已收取的现金付款。", "shiftCloseUnpaidMoreCount": "另有 +{count} 笔未结订单", - "lockTerminal": "锁定终端" + "lockTerminal": "锁定终端", + "selectOpenShift": "要关闭的班次", + "xReportShiftFilterLabel": "班次范围", + "xReportAllShifts": "所有班次(全天)" }, "printTest": { "amt": "金额", @@ -2571,7 +2574,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 7de8b04a9..a0af189b0 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'; @@ -3782,8 +3783,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); @@ -5978,7 +5980,7 @@ router.patch( const session = db .prepare('SELECT id, status FROM cash_sessions WHERE id = ?') .get(rawTargetSessionId) as { id: number; status: string } | undefined; - const openSession = getOpenSession(db); + const openSession = isPerStaffShiftsEnabled(db) ? session : getOpenSession(db); if ( !session || session.status !== 'open' || diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index 04528b8d7..2f2e4180e 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -44,7 +44,7 @@ import { import { requirePermission } from '../services/authorization'; import { nextZNumber } from '../db'; import { getTenantCurrency } from '../services/refund'; -import { openSessionBlockingDayClose } from '../services/cash-day-close-guards'; +import { openSessionsBlockingDayClose } from '../services/cash-day-close-guards'; import { getOpenSession, NO_CASH_SESSION_ID, @@ -213,14 +213,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[]; } @@ -234,7 +236,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 @@ -242,7 +245,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[]; } @@ -251,12 +255,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; } @@ -392,7 +398,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 @@ -435,7 +442,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, @@ -519,7 +527,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' ), @@ -562,7 +571,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; @@ -629,7 +639,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, @@ -675,7 +686,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; @@ -706,7 +718,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, @@ -747,7 +760,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; @@ -779,18 +793,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. @@ -799,14 +816,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'` @@ -841,8 +860,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 @@ -853,21 +910,66 @@ 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(` + type TaxBillRow = { + id: number; + order_id: number; + tax_amount: number | null; + tax_snapshot: string | null; + tax_breakdown: string | null; + }; + 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 TaxBillRow[]) + : (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 TaxBillRow[]); const orders = getOrdersWithItemsForBills(db, bills); const taxDocuments = bills.map((bill) => ({ tax_amount: bill.tax_amount, @@ -915,7 +1017,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); @@ -942,12 +1047,41 @@ router.post( movementType === 'opening_float', ); const reason = validateMovementReason(body.reason, movementType !== 'opening_float'); - const createdBy = String((req as any).user?.userId || ''); + 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(); - // Shift enforcement (#279): every movement touches the drawer. - requireOpenSessionForCash(db); 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, { @@ -956,7 +1090,7 @@ router.post( amountCents, reason, createdBy, - cashSessionId: getOpenSession(db)?.id ?? NO_CASH_SESSION_ID, + cashSessionId: targetSessionId, }); } catch (error: any) { if (error.code === 'day_closed') { @@ -980,13 +1114,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) { @@ -1055,22 +1191,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) { @@ -1125,7 +1265,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 +1280,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, + })), }, ); } @@ -1172,11 +1319,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); @@ -1195,7 +1344,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, @@ -1213,7 +1363,8 @@ router.post('/', requirePermission('cash.day-close'), (req: Request, res: Respon ?, ?, ?, ?, ?, ?, ?, ? ) - `).run( + `, + ).run( businessDate, periodStart, periodEnd, @@ -1319,6 +1470,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..808a32c92 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. */ @@ -25,7 +26,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, @@ -125,8 +131,8 @@ 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), }; const expectedCashCents = sessionExpectedCash(db, session, closedAt, sessionMovements); @@ -139,7 +145,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, @@ -157,7 +164,8 @@ function closeSessionTxn( ?, ?, ?, ?, ?, ?, ?, ? ) - `) + `, + ) .run( businessDate, session.opened_at, @@ -223,7 +231,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( @@ -238,7 +247,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) { @@ -252,8 +262,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(), @@ -263,29 +279,38 @@ 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 // 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(` + 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 = ?`, + ).run(openingFloatCents, existingFloat.id); } } const openedSession = db.prepare(`SELECT * FROM cash_sessions WHERE id = ?`).get(sessionId); @@ -329,17 +354,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' }); + 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); @@ -365,7 +440,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 +514,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..573e5bccd 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,13 @@ router.post( asyncHandler(async (req: Request, res: Response) => { try { const db = getDatabase(); - if (!getOpenSession(db)) { + 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); + 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 0564396ed..227138c6a 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 { + openSessionsBlockingDayClose, + sessionBusinessDate, +} from '../services/cash-day-close-guards'; +import type { CashSessionRow } from '../services/shift-session-gate'; +import type { AuthedRequest } from './cash-sessions'; import { computeDayAggregates, + computePeriodAggregates, expectedCashFromAggregates, + listCashDrawerMovementsForSession, paymentMethodBreakdown, + sessionExpectedCash, + sessionFinancialTotals, } from './cash-closures'; import { getCurrencyFractionDigits } from '../countries'; import { roundCurrencyCost } from '../services/supplies'; @@ -1251,10 +1261,139 @@ 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)); + + 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 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' }); + } + 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 = + 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 + 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 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, + 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, + session, + ), + ...sessionFinancialTotals(db, session, sessionEnd), + }; + const totalExpectedCashCents = sessionExpectedCash(db, session, sessionEnd, sessionMovements); + return res.json({ + xReport: { + businessDate: shiftDate, + 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: sessionPriorRow?.counted_cash_cents ?? null, + priorBusinessDate: sessionPriorRow?.business_date ?? null, + alreadyClosed: session.status === 'closed' || !!sessionClosedRow, + zNumber: sessionClosedRow?.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, + closure_id: session.closure_id, + opening_float_cents: openingFloatCents, + expected_cash_cents: totalExpectedCashCents, + }, + openShifts: sessionOpenShifts, + blockingOpenShift: sessionBlocking + ? { + 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); @@ -1276,7 +1415,14 @@ 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, + })); res.json({ xReport: { @@ -1321,6 +1467,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..7278a1d39 100644 --- a/main/services/shift-session-gate.ts +++ b/main/services/shift-session-gate.ts @@ -29,10 +29,36 @@ 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' LIMIT 1`).get() as + | CashSessionRow + | undefined; + } + const normalizedUserId = typeof userId === 'string' ? userId.trim() : ''; + if (!normalizedUserId) return undefined; + return db + .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']); @@ -67,9 +93,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 +110,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/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), diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index 927191d88..b861b943d 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); }; @@ -129,17 +135,26 @@ 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 }[] + 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 = ( @@ -188,6 +203,9 @@ async function main() { app.use(express.json()); app.use(expressRateLimit({ windowMs: 60 * 1000, limit: 1000 })); app.use((req: any, res: any, next: any) => { + if (req.path.startsWith('/api/auth') && !req.path.includes('/api/auth/me')) { + return next(); + } const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Authentication required' }); @@ -286,17 +304,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, @@ -404,17 +422,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, ); @@ -807,38 +825,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 }, @@ -928,20 +956,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, @@ -969,37 +994,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) @@ -1069,10 +1099,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}`) @@ -1273,7 +1303,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' }, ); @@ -1375,7 +1409,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`) @@ -1393,7 +1431,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})`, @@ -1453,20 +1495,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}`) @@ -1504,23 +1543,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}`); @@ -1588,7 +1621,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 || '')), @@ -1615,7 +1653,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') @@ -1693,12 +1733,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)', @@ -1739,17 +1787,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) @@ -1817,7 +1873,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(), ); @@ -1952,7 +2012,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 = ?`, @@ -2266,6 +2330,1027 @@ 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', + ); + // 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(); + }); + + // 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 && + 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) + .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 && + 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) + .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)', + ); + 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) + .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})`, + ); + 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) + .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)', + ); + }); + + // 8) Combined v3.14.0 multi-feature acceptance (#1338 PIN quick-switch + #1337 payment method correction + #1339 per-staff shifts) + console.log( + 'Section 12: combined v3.14.0 multi-feature acceptance (PIN switch + per-staff shifts + payment correction + X/Z export)', + ); + const { authRoutes } = require('../main/routes/auth'); + const { orderRoutes } = require('../main/routes/orders'); + app.use('/api/auth', authRoutes); + app.use('/api/orders', orderRoutes); + + db.prepare( + `INSERT OR IGNORE INTO categories (id, name, sort_order, is_active, created_at, updated_at) + VALUES ('cat-v314', 'Combo Menu', 1, 1, ?, ?)`, + ).run(now(), now()); + db.prepare( + `INSERT OR IGNORE INTO products (id, category_id, name, price, tax_rate, tax_type, is_active, track_inventory, stock_quantity, low_stock_threshold, sort_order, created_at, updated_at) + VALUES ('prod-v314', 'cat-v314', 'Combo Platter', 75, 0, 'none', 1, 0, 0, 0, 1, ?, ?)`, + ).run(now(), now()); + + db.prepare('UPDATE users SET pin_hash = ? WHERE id = ?').run( + bcrypt.hashSync('1111', 10), + 'cashier-sess', + ); + db.prepare('UPDATE users SET pin_hash = ? WHERE id = ?').run( + bcrypt.hashSync('2222', 10), + 'cashier2-sess', + ); + db.prepare('UPDATE users SET pin_hash = ? WHERE id = ?').run( + bcrypt.hashSync('4321', 10), + 'manager-sess', + ); + + await withFixedClock('2026-11-12T10:00:00.000Z', async () => { + // Step 1: Enable one_shift_per_staff = 'true' + const enableComboPerStaff = await request(app) + .put('/api/settings/one_shift_per_staff') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ value: true }); + assert( + enableComboPerStaff.status === 200 && getSettingValue('one_shift_per_staff') === 'true', + 'v3.14.0 combo: enable one_shift_per_staff = true (200)', + ); + + // Step 2: Staff A (cashier) and Staff B (cashier) each open their own cash shift + const openComboA = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 4000 }); + const openComboB = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 6000 }); + assert( + openComboA.status === 200 && openComboB.status === 200, + 'v3.14.0 combo: Staff A (4000c float) and Staff B (6000c float) each open their own shift (200)', + ); + const comboShiftAId = Number(openComboA.body?.id); + const comboShiftBId = Number(openComboB.body?.id); + + // Step 3: Staff A builds a cart/order and generates bill, then locks POS and switches via POST /api/auth/pin-login to Staff B + const orderComboRes = await request(app) + .post('/api/orders') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ + type: 'takeaway', + items: [{ product_id: 'prod-v314', quantity: 1 }], + }); + assert( + orderComboRes.status === 201 && orderComboRes.body?.order?.user_id === 'cashier-sess', + 'v3.14.0 combo: Staff A creates order (201)', + ); + const comboOrderId = Number(orderComboRes.body?.order?.id); + + const billComboRes = await request(app) + .post('/api/bills/generate') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ order_id: comboOrderId }); + assert(billComboRes.status === 201, 'v3.14.0 combo: Staff A generates bill (201)'); + const comboBillId = Number(billComboRes.body?.bill?.id); + + const pinSwitchToB = await request(app) + .post('/api/auth/pin-login') + .send({ user_id: 'cashier2-sess', pin: '2222' }); + assert( + pinSwitchToB.status === 200 && + pinSwitchToB.body?.user?.id === 'cashier2-sess' && + typeof pinSwitchToB.body?.access_token === 'string', + 'v3.14.0 combo: POS quick-switches from Staff A to Staff B via POST /api/auth/pin-login (200)', + ); + const staffBPinToken = String(pinSwitchToB.body.access_token); + + // Step 4: Staff B takes a cash payment on that bill -> verify attributed to Staff B's open shift, not Staff A's + const payComboByB = await request(app) + .post(`/api/bills/${comboBillId}/payment`) + .set('Authorization', `Bearer ${staffBPinToken}`) + .send({ method: 'cash', amount: 75 }); + assert(payComboByB.status === 200, 'v3.14.0 combo: Staff B takes 75.00 cash payment (200)'); + + const comboBillRowBefore = db + .prepare('SELECT payment_details FROM bills WHERE id = ?') + .get(comboBillId) as { payment_details: string }; + const comboDetailsBefore = JSON.parse(comboBillRowBefore.payment_details); + assert( + Number(comboDetailsBefore[0]?.cash_session_id) === comboShiftBId && + Number(comboDetailsBefore[0]?.cash_session_id) !== comboShiftAId, + `v3.14.0 combo: cash payment is attributed to Staff B shift (${comboShiftBId}), not Staff A shift (${comboShiftAId})`, + ); + + const curComboAAfterPay = await request(app) + .get('/api/cash-sessions/current') + .set('Authorization', `Bearer ${s11CashierAToken}`); + const curComboBAfterPay = await request(app) + .get('/api/cash-sessions/current') + .set('Authorization', `Bearer ${staffBPinToken}`); + assert( + curComboAAfterPay.body?.expected_cash_cents === 4000 && + curComboBAfterPay.body?.expected_cash_cents === 13500, + `v3.14.0 combo: Staff B expected cash rises to 13500c (6000 + 7500) while Staff A stays at 4000c`, + ); + + // Step 5: Staff B (cashier) attempting PATCH /api/bills/:id/payments/0/method is rejected with 403 even with Manager M's override_pin + const cashierCorrectAttempt = await request(app) + .patch(`/api/bills/${comboBillId}/payments/0/method`) + .set('Authorization', `Bearer ${staffBPinToken}`) + .set('Idempotency-Key', 'v314-combo-cashier-blocked') + .send({ + method: 'card', + transaction_id: 'TX-BLOCKED-B', + reason: 'Cashier attempting correction with manager PIN', + approver_id: 'manager-sess', + override_pin: '4321', + }); + assert( + cashierCorrectAttempt.status === 403 && + cashierCorrectAttempt.body?.reason === 'insufficient_role_permissions', + `v3.14.0 combo: Staff B (cashier) is rejected on PATCH payment method with 403 insufficient_role_permissions even with valid manager PIN`, + ); + const comboAuditCountBlocked = ( + db + .prepare( + `SELECT COUNT(*) AS c FROM order_audit_log WHERE order_id = ? AND action = 'bill.payment_method.corrected'`, + ) + .get(comboOrderId) as { c: number } + ).c; + const comboIdemCountBlocked = ( + db + .prepare(`SELECT COUNT(*) AS c FROM payment_idempotency WHERE bill_id = ?`) + .get(String(comboBillId)) as { c: number } + ).c; + assert( + comboAuditCountBlocked === 0 && comboIdemCountBlocked === 0, + 'v3.14.0 combo: rejected cashier correction writes 0 audit rows and 0 idempotency rows', + ); + + // Step 6: Manager M signs in via PIN login and corrects tender from cash to card -> stays in Staff B's shift + const pinSwitchToMgr = await request(app) + .post('/api/auth/pin-login') + .send({ user_id: 'manager-sess', pin: '4321' }); + assert( + pinSwitchToMgr.status === 200 && + pinSwitchToMgr.body?.user?.id === 'manager-sess' && + typeof pinSwitchToMgr.body?.access_token === 'string', + 'v3.14.0 combo: Manager M signs in via POST /api/auth/pin-login (200)', + ); + const managerPinToken = String(pinSwitchToMgr.body.access_token); + + const mgrCorrectRes = await request(app) + .patch(`/api/bills/${comboBillId}/payments/0/method`) + .set('Authorization', `Bearer ${managerPinToken}`) + .set('Idempotency-Key', 'v314-combo-mgr-correct') + .send({ + method: 'card', + transaction_id: 'TX-COMBO-1339', + reason: 'Customer paid by card terminal', + approver_id: 'manager-sess', + override_pin: '4321', + }); + assert( + mgrCorrectRes.status === 200 && + mgrCorrectRes.body?.bill?.payment_details?.[0]?.method === 'card' && + Number(mgrCorrectRes.body?.bill?.payment_details?.[0]?.amount) === 75 && + Number(mgrCorrectRes.body?.bill?.payment_details?.[0]?.cash_session_id) === + comboShiftBId, + `v3.14.0 combo: Manager M corrects cash -> card (200), amount stays 75, and payment remains in Staff B shift (${comboShiftBId})`, + ); + + const curComboAAfterCorrect = await request(app) + .get('/api/cash-sessions/current') + .set('Authorization', `Bearer ${s11CashierAToken}`); + const curComboBAfterCorrect = await request(app) + .get('/api/cash-sessions/current') + .set('Authorization', `Bearer ${staffBPinToken}`); + assert( + curComboBAfterCorrect.body?.expected_cash_cents === 6000 && + curComboAAfterCorrect.body?.expected_cash_cents === 4000, + `v3.14.0 combo: after cash -> card correction, Staff B expected cash drops to 6000c (float only) and Staff A remains untouched at 4000c`, + ); + + const xComboShiftB = await request(app) + .get(`/api/reports/x-report?date=2026-11-12&session_id=${comboShiftBId}`) + .set('Authorization', `Bearer ${managerPinToken}`); + const xComboShiftBRep = xComboShiftB.body?.xReport; + assert( + xComboShiftB.status === 200 && + xComboShiftBRep?.expectedCashCents === 0 && + xComboShiftBRep?.session?.expected_cash_cents === 6000 && + xComboShiftBRep?.grossCollected === 75 && + Array.isArray(xComboShiftBRep?.paymentMethods) && + xComboShiftBRep.paymentMethods.length === 1 && + String(xComboShiftBRep.paymentMethods[0]?.method).toLowerCase() === 'card' && + Number(xComboShiftBRep.paymentMethods[0]?.total) === 75, + 'v3.14.0 combo: Staff B shift X-report reflects corrected Card method (75) and 0 net cash sales', + ); + + // Step 7: Day close is blocked with 409 while Staff A's or Staff B's shift is open, then succeeds once both close + const comboDayBlockedBoth = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-12', + opening_float_cents: 10000, + counted_cash_cents: 10000, + }); + assert( + comboDayBlockedBoth.status === 409 && + comboDayBlockedBoth.body?.display_code === 'open_shift_blocks_day_close', + 'v3.14.0 combo: day close blocked (409) while both Staff A and Staff B shifts are open', + ); + + const closeComboA = await request(app) + .post(`/api/cash-sessions/${comboShiftAId}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 4000 }); + assert( + closeComboA.status === 200 && closeComboA.body?.variance_cents === 0, + 'v3.14.0 combo: Staff A closes Shift A with 4000c (200)', + ); + + const comboDayBlockedBOpen = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-12', + opening_float_cents: 10000, + counted_cash_cents: 10000, + }); + assert( + comboDayBlockedBOpen.status === 409 && + comboDayBlockedBOpen.body?.display_code === 'open_shift_blocks_day_close' && + Number(comboDayBlockedBOpen.body?.blocking_shift?.id) === comboShiftBId, + 'v3.14.0 combo: day close remains blocked (409) while Staff B shift is still open', + ); + + const closeComboB = await request(app) + .post(`/api/cash-sessions/${comboShiftBId}/close`) + .set('Authorization', `Bearer ${staffBPinToken}`) + .send({ counted_cash_cents: 6000 }); + assert( + closeComboB.status === 200 && closeComboB.body?.variance_cents === 0, + 'v3.14.0 combo: Staff B closes Shift B with 6000c (200)', + ); + + const comboDayClose = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-12', + opening_float_cents: 10000, + counted_cash_cents: 10000, + }); + assert( + comboDayClose.status === 201 && + comboDayClose.body?.zReport?.opening_float_cents === 10000 && + comboDayClose.body?.zReport?.expected_cash_cents === 10000 && + comboDayClose.body?.zReport?.counted_cash_cents === 10000 && + comboDayClose.body?.zReport?.variance_cents === 0 && + comboDayClose.body?.zReport?.gross_collected_cents === 7500 && + comboDayClose.body?.zReport?.net_collected_cents === 7500 && + comboDayClose.body?.zReport?.bill_count === 1, + 'v3.14.0 combo: day close succeeds once both shifts are closed and reconciles totals (201)', + ); + + // Step 8: Verify X report and Z report JSON and CSV exports reconcile with the corrected payment method and totals + const xComboDay = await request(app) + .get('/api/reports/x-report?date=2026-11-12') + .set('Authorization', `Bearer ${managerPinToken}`); + const zComboDay = await request(app) + .get('/api/reports/z-report?date=2026-11-12') + .set('Authorization', `Bearer ${managerPinToken}`); + assert( + xComboDay.status === 200 && + zComboDay.status === 200 && + xComboDay.body?.xReport?.expectedCashCents === 0 && + xComboDay.body?.xReport?.openingFloatCents === 10000 && + xComboDay.body?.xReport?.grossCollected === 75 && + zComboDay.body?.zReport?.expected_cash_cents === 10000 && + zComboDay.body?.zReport?.gross_collected_cents === 7500 && + Array.isArray(zComboDay.body?.zReport?.payment_methods) && + zComboDay.body.zReport.payment_methods.length === 1 && + String(zComboDay.body.zReport.payment_methods[0]?.method).toLowerCase() === 'card' && + Number(zComboDay.body.zReport.payment_methods[0]?.total_cents) === 7500, + 'v3.14.0 combo: day X-report and Z-report JSON reconcile with corrected Card payment and 0 cash sales', + ); + + const xCsvExport = await request(app) + .get('/api/reports/x-report/export?date=2026-11-12&format=csv') + .set('Authorization', `Bearer ${managerPinToken}`); + const zCsvExport = await request(app) + .get('/api/reports/z-report/export?date=2026-11-12&format=csv') + .set('Authorization', `Bearer ${managerPinToken}`); + assert( + xCsvExport.status === 200 && + xCsvExport.text.includes('summary,Gross Sales,,,,75') && + xCsvExport.text.includes('summary,Net Collections,,,,75') && + xCsvExport.text.includes('summary,Opening Float,,,,100') && + xCsvExport.text.includes('summary,Expected Cash in Drawer,,,,100') && + /payment,,card,,1,75/i.test(xCsvExport.text) && + !/payment,,cash,/i.test(xCsvExport.text), + 'v3.14.0 combo: X-report CSV export reconciles with corrected Card payment and expected cash', + ); + assert( + zCsvExport.status === 200 && + zCsvExport.text.includes('summary,Gross Sales,,,,75') && + zCsvExport.text.includes('summary,Net Collections,,,,75') && + zCsvExport.text.includes('summary,Opening Float,,,,100') && + zCsvExport.text.includes('summary,Expected Cash in Drawer,,,,100') && + zCsvExport.text.includes('summary,Counted Cash,,,,100') && + zCsvExport.text.includes('summary,Cash Variance,,,,0') && + /payment,,card,,1,75/i.test(zCsvExport.text) && + !/payment,,cash,/i.test(zCsvExport.text), + 'v3.14.0 combo: Z-report CSV export reconciles with corrected Card payment and expected cash', + ); + }); + } 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/payment-method-correction.test.ts b/tests/payment-method-correction.test.ts index fc0232eed..3bbf6e1a6 100644 --- a/tests/payment-method-correction.test.ts +++ b/tests/payment-method-correction.test.ts @@ -1183,6 +1183,115 @@ async function main() { 'correcting split check when sibling was settled in a closed shift returns 409', ); + // ── 12b. Per-staff shifts (one_shift_per_staff = true): manager correcting Staff B payment keeps it in Staff B shift ─ + db.prepare( + "INSERT INTO settings (key, value) VALUES ('one_shift_per_staff', 'true') ON CONFLICT(key) DO UPDATE SET value = 'true'", + ).run(); + const cashierBId = 'cashier-b-1337'; + db.prepare( + `INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) + VALUES (?, 'Cashier B', 'cashier-b@test.local', ?, 'cashier', ?, 1, ?, ?)`, + ).run( + cashierBId, + bcrypt.hashSync('password123', 10), + bcrypt.hashSync('6666', 10), + now(), + now(), + ); + const cashierBAuth = { + Authorization: `Bearer ${jwt.sign( + { userId: cashierBId, email: 'cashier-b@test.local', role: 'cashier' }, + getJWTSecret(), + { expiresIn: '1h' }, + )}`, + }; + const openShiftARes = await api(baseUrl, '/api/cash-sessions/open', { + method: 'POST', + body: { opening_float_cents: 3000 }, + headers: cashierAuth, + }); + assertEqualOrThrow(openShiftARes.status, 200, 'Staff A opens own cash shift (200)'); + const shiftAId = openShiftARes.data.id; + + const openShiftBRes = await api(baseUrl, '/api/cash-sessions/open', { + method: 'POST', + body: { opening_float_cents: 4000 }, + headers: cashierBAuth, + }); + assertEqualOrThrow(openShiftBRes.status, 200, 'Staff B opens own cash shift (200)'); + const shiftBId = openShiftBRes.data.id; + + const { bill: staffBBill } = await createPaidBill('prod-80', 'cash', {}, cashierBAuth); + assertEqualOrThrow( + staffBBill.payment_details[0].cash_session_id, + shiftBId, + 'Staff B cash payment is attributed to Staff B open shift', + ); + + const curShiftBBefore = await api(baseUrl, '/api/cash-sessions/current', { + headers: cashierBAuth, + }); + const curShiftMgrBefore = await api(baseUrl, '/api/cash-sessions/current', { + headers: managerAuth, + }); + assertEqualOrThrow( + curShiftBBefore.data.expected_cash_cents, + 12000, + 'Staff B shift expected cash is 4000 float + 8000 sale = 12000 before correction', + ); + + const mgrCorrectsStaffBBill = await api( + baseUrl, + `/api/bills/${staffBBill.id}/payments/0/method`, + { + method: 'PATCH', + headers: managerAuth, + body: { + method: 'card', + transaction_id: 'TX-STAFF-B-CARD', + reason: 'Manager correcting Staff B payment method', + approver_id: managerId, + override_pin: '1234', + }, + }, + ); + assertEqualOrThrow( + mgrCorrectsStaffBBill.status, + 200, + 'manager corrects Staff B cash payment to card while Staff A, Staff B, and manager shifts are open (200)', + ); + assertEqualOrThrow( + mgrCorrectsStaffBBill.data.bill.payment_details[0].cash_session_id, + shiftBId, + 'corrected payment stays in Staff B shift and does not move to manager shift', + ); + const curShiftAAfter = await api(baseUrl, '/api/cash-sessions/current', { + headers: cashierAuth, + }); + const curShiftBAfter = await api(baseUrl, '/api/cash-sessions/current', { + headers: cashierBAuth, + }); + const curShiftMgrAfter = await api(baseUrl, '/api/cash-sessions/current', { + headers: managerAuth, + }); + assertEqualOrThrow( + curShiftBAfter.data.expected_cash_cents, + 4000, + 'Staff B shift expected cash drops to 4000 (float only) after cash -> card correction', + ); + assertEqualOrThrow( + curShiftAAfter.data.expected_cash_cents, + 3000, + 'Staff A shift expected cash remains untouched at 3000', + ); + assertEqualOrThrow( + curShiftMgrAfter.data.expected_cash_cents, + curShiftMgrBefore.data.expected_cash_cents, + 'Manager shift expected cash remains untouched', + ); + db.prepare("UPDATE settings SET value = 'false' WHERE key = 'one_shift_per_staff'").run(); + db.prepare('DELETE FROM cash_sessions WHERE id IN (?, ?)').run(shiftAId, shiftBId); + // ── 13. Closed business day refusal (409) ───────────────────────────── const { bill: bill3 } = await createPaidBill('prod-80', 'cash'); const businessDate = ( 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));