diff --git a/docs/architecture/business-time.md b/docs/architecture/business-time.md index cd0d222ef..df3789ed2 100644 --- a/docs/architecture/business-time.md +++ b/docs/architecture/business-time.md @@ -133,7 +133,8 @@ actor's open shift (`getOpenSession(db, userId)`). Day close (`POST /api/cash-cl 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. +isolated in a shared drawer. For cross-shift bill tax attribution on shift X/Z reports, see +[Taxation](taxation.md#where-the-amount-came-from). ### Refunds diff --git a/docs/architecture/taxation.md b/docs/architecture/taxation.md index 7b38883e0..a25d60de5 100644 --- a/docs/architecture/taxation.md +++ b/docs/architecture/taxation.md @@ -182,10 +182,20 @@ reinterpreted as a per-line amount. Refunds read the snapshot. Recomputing tax at refund time would produce a different answer if the pack had been updated in between, which is the exact failure the snapshot exists to prevent. -The reporting counterpart is `GET /api/reports/tax-components`, backed by -[`main/services/tax-components.ts`](../../main/services/tax-components.ts). It derives components -item by item so a bill that mixes categorized and uncategorized lines cannot double-count the -categorized portion against the bill-level `tax_breakdown`. +The reporting counterparts are `GET /api/reports/tax-components` and X/Z cash-closure reporting in +[`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts), both backed by +[`main/services/tax-components.ts`](../../main/services/tax-components.ts). `resolveTaxComponents()` +derives components item by item from stored snapshots and breakdowns so a bill mixing categorized and +uncategorized lines cannot double-count the categorized portion against the bill-level +`tax_breakdown`, and never recomputes historical tax from live tax settings. Whole-day X/Z reports +aggregate paid bills in the day window through `aggregateTaxComponents()`. Session-scoped X/Z reports +(`computeSessionAggregates()`) include settled bills and partially paid bills on non-cancelled +orders: a bill settled wholly within +one shift keeps `resolveTaxComponents()` as-is, while partial or cross-shift bills allocate resolved +components sequentially across payment lines against the remaining bill total in minor units +(`allocateSignedMinorUnits()`, `reconcileSnapshotAllocations()`, and `mergeDisplayTaxComponents()`), +so a shift closed before settlement keeps its collected tax share in its immutable Z snapshot and +subsequent shifts receive the exact remaining tax. ## Bundled packs @@ -318,8 +328,9 @@ author cannot rely on any of them. - **`storeId` on a pack.** No pack schema field references a store. - **Customer tax fields.** A customer carries a registration number, state code, and exempt flag through `TaxCustomer`, and nothing tax-address-shaped beyond that. -- **Reporting by anything other than tax component.** The report surface is - `GET /api/reports/tax-components`. There is no reporting by rule, by category, or by jurisdiction. +- **Reporting by anything other than tax component.** Tax reporting surfaces (such as + `GET /api/reports/tax-components` and X/Z cash-closure reports) group only by resolved tax + component; there is no reporting by rule, by category, or by jurisdiction. ## Verification diff --git a/docs/reference/api.md b/docs/reference/api.md index 75a24664d..9d1e3734c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -864,7 +864,7 @@ day-boundary rules. | `GET` | `/insights` | `ROLE_ACCESS.ownerManager` | query: `?days`, `?start_date`, `?end_date` | - | | `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` | `/x-report/export` | `reports.view` (`ROLE_ACCESS.ownerManager` by default) | query: `?date`, `?format` (required: `csv`, `xlsx`, or `html`), optional `?session_id` | Exports a live X-report snapshot for one business date or, when `?session_id` is provided, for that shift (`Shift # - ` in the `Summary` rows and `format=html` header, with the same `400 session_id_invalid`, `404 session_unavailable`, and `403 shift_access_forbidden` checks as `GET /x-report`; 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-."` (or `attachment; filename="x-report--shift-."` when `?session_id` is provided). | | `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-."`. | | `POST` | `/cash-closes/:id/whatsapp` | `reports.view` + `whatsapp.use` | path: `id`; body: `phone_e164`, `body` (non-empty, max 4096 chars) | Sends the submitted report body and returns `{ success: true, messageId }`; when WhatsApp is disconnected, returns `{ fallback: true, reason: "not_connected" }` so the client can open WhatsApp for sharing. Invalid input returns `400`; an unknown day close returns `404`. | | `GET` | `/daily-sales/export` | `reports.daily-sales.export` (owner by default) | query: `?date`, `?from`, `?to`, `?format`, `?part` | See [GET `/api/reports/daily-sales/export`](#get-apireportsdaily-salesexport). | diff --git a/docs/reference/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index 0b3a19e41..2126c5ca9 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -156,15 +156,16 @@ every save. **Cash shifts.** Opening, viewing, and closing shifts are gated by `cash.shifts.open`, `cash.shifts.view`, and `cash.shifts.close`. Non-`owner`/`manager` roles (such as `cashier`) may -only view, preview, close, 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). +only view, preview, close, attach explicit drawer movements to, or read/export the shift X-report +for the shift they opened (`403` when targeting another user's shift), whereas an `owner` or +`manager` can inspect, export, 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), +[business time](../architecture/business-time.md#cash-closures), and per-route error codes in +[Cash sessions](api.md#cash-sessions), [Cash closures](api.md#cash-closures), and +[Reports](api.md#reports). **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/src/hooks/useCashClose.ts b/frontend/src/hooks/useCashClose.ts index 1b63d1515..1dcf782d3 100644 --- a/frontend/src/hooks/useCashClose.ts +++ b/frontend/src/hooks/useCashClose.ts @@ -424,20 +424,30 @@ export function useCashClose() { setExportingReport(true); try { const endpoint = `/reports/${report}-report/export`; + const isShiftScopedX = report === 'x' && selectedSessionId !== null; + const exportDate = + isShiftScopedX && xReport?.session?.id === selectedSessionId && xReport.businessDate + ? xReport.businessDate + : businessDate; const baseFilename = report === 'x' - ? `x-report-${businessDate}` + ? isShiftScopedX + ? `x-report-${exportDate}-shift-${selectedSessionId}` + : `x-report-${businessDate}` : `z-report-Z${closedZ!.z_number}-${businessDate}`; + const baseParams = isShiftScopedX + ? { date: exportDate, session_id: selectedSessionId } + : { date: businessDate }; if (format === 'pdf') { await exportReportAsPdf({ endpoint, - params: { date: businessDate }, + params: baseParams, filename: `${baseFilename}.pdf`, }); return; } const res = await api.get(endpoint, { - params: { date: businessDate, format }, + params: { ...baseParams, format }, responseType: 'blob', }); downloadBlob(res.data as Blob, `${baseFilename}.${format}`); diff --git a/main/routes/bills.ts b/main/routes/bills.ts index a0af189b0..cdea5af07 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -1357,7 +1357,7 @@ interface TaxSnapshotAllocationResult { exclusiveTaxMinors: number[] | null; } -function reconcileSnapshotAllocations( +export function reconcileSnapshotAllocations( entries: Array<{ allocations: number[] }>, targets: number[], ): void { diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index 2f2e4180e..cd6dd1b5a 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -59,10 +59,20 @@ import { } from '../services/cash-drawer-movements'; // Type-only: erased at compile, so this adds no runtime require cycle. import type { AuthedRequest } from './cash-sessions'; +import Decimal from 'decimal.js'; import { getCurrencyMinorUnitFactor } from '../countries'; -import { getOrdersWithItemsForBills } from './bills'; +import { + allocateSignedMinorUnits, + getOrdersWithItemsForBills, + reconcileSnapshotAllocations, +} from './bills'; import { getHttpRequestSignal } from '../shutdown'; -import { DisplayTaxComponent, aggregateTaxComponents } from '../services/tax-components'; +import { + DisplayTaxComponent, + aggregateTaxComponents, + mergeDisplayTaxComponents, + resolveTaxComponents, +} from '../services/tax-components'; import { Z_REPORT_LANGUAGE_POLICY_KEY, parseStoredLanguagePolicy, @@ -513,6 +523,25 @@ export function expectedCashFromAggregates( aggregates.cashRefundsByCreatedAtCents ); } +// `b.paid_at` represents a timestamp-less line only when it is the single/final settling +// line; earlier pre-settlement installments stay anchored to `b.created_at`. +const LEGACY_STABLE_PAYMENT_LINE_TIME_SQL = `( + CASE + WHEN json_valid(b.payment_details) + AND json_type(b.payment_details) = 'array' + AND CAST(je.key AS INTEGER) < json_array_length(b.payment_details) - 1 + THEN COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.created_at, '')) + ) + ELSE COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) + END +)`; + /** * Drawer-reality cash inputs for one raw timestamp window: cash sales by each * payment line's timestamp (includes partial tenders before `paid_at` is set) @@ -537,11 +566,7 @@ export function cashDrawerSalesAndRefunds( CAST(json_extract(je.value, '$.amount') AS REAL) AS amount, COALESCE(NULLIF(json_extract(je.value, '$.method'), ''), '') AS method, CAST(json_extract(je.value, '$.payment_method_id') AS INTEGER) AS payment_method_id, - COALESCE( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), - datetime(NULLIF(b.paid_at, '')), - datetime(NULLIF(b.created_at, '')) - ) AS line_time + ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} AS line_time FROM bills b JOIN json_each( CASE @@ -647,11 +672,7 @@ export function sessionExpectedCash( COALESCE(NULLIF(json_extract(je.value, '$.method'), ''), '') AS method, CAST(json_extract(je.value, '$.payment_method_id') AS INTEGER) AS payment_method_id, 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 + ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} AS line_time FROM bills b JOIN json_each( CASE @@ -702,7 +723,7 @@ export function sessionExpectedCash( ); } -/** Sales and refunds attributed to a session for its immutable Z snapshot. */ +/** Sales and refunds attributed to a session for shift X/Z reports. */ export function sessionFinancialTotals( db: ReturnType, session: { id: number; opened_at: string }, @@ -725,8 +746,7 @@ export function sessionFinancialTotals( CAST(ROUND(CAST(json_extract(je.value, '$.amount') AS REAL) * ?) AS INTEGER) AS amount_cents, COALESCE(NULLIF(json_extract(je.value, '$.method'), ''), 'unknown') AS method, 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 + ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} 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 @@ -781,6 +801,70 @@ export function sessionFinancialTotals( }; } +function roundComponentsToMinorUnits( + components: DisplayTaxComponent[], + minorFactor: number, +): { compMinors: number[]; totalMinor: number } { + const exactMinors = components.map((comp) => new Decimal(comp.amount).times(minorFactor)); + if (exactMinors.every((m) => m.isInteger())) { + const compMinors = exactMinors.map((m) => m.toNumber()); + return { + compMinors, + totalMinor: compMinors.reduce((sum, minor) => sum + minor, 0), + }; + } + const totalExact = exactMinors.reduce((sum, m) => sum.plus(m), new Decimal(0)); + const totalMinor = totalExact.toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toNumber(); + const compMinors = exactMinors.map((m) => + m.isNegative() ? m.ceil().toNumber() : m.floor().toNumber(), + ); + const delta = totalMinor - compMinors.reduce((sum, minor) => sum + minor, 0); + if (delta !== 0 && compMinors.length > 0) { + const sign = delta > 0 ? 1 : -1; + const ranked = exactMinors + .map((m, index) => ({ + index, + remainder: + sign > 0 + ? m.minus(compMinors[index]).toNumber() + : new Decimal(compMinors[index]).minus(m).toNumber(), + })) + .sort((a, b) => + Math.abs(b.remainder - a.remainder) > 1e-9 ? b.remainder - a.remainder : a.index - b.index, + ); + for (let i = 0; i < Math.abs(delta); i++) { + compMinors[ranked[i % ranked.length].index] += sign; + } + } + return { compMinors, totalMinor }; +} + +export function computeSessionAggregates( + db: ReturnType, + session: { id: number; opened_at: string; opening_float_cents: number }, + end: string, +): DayAggregates { + const sessionMovements = listCashDrawerMovementsForSession( + db, + session.id, + session.opened_at, + end, + false, + ); + const openingFloatCents = Number(session.opening_float_cents || 0); + return { + ...computePeriodAggregates( + db, + session.opened_at, + end, + sessionMovements, + openingFloatCents, + session, + ), + ...sessionFinancialTotals(db, session, end), + }; +} + /** * Window-based aggregation shared by day close and session close (#279). * Same queries as the day pipeline, parameterized by raw timestamp window @@ -868,8 +952,7 @@ export function computePeriodAggregates( 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 + ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} 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 @@ -914,13 +997,14 @@ export function computePeriodAggregates( ) .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. + // Day scope aggregates paid bills in [start, end); session scope allocates stored + // historical tax components sequentially across payment lines in minor units. type TaxBillRow = { id: number; order_id: number; + split_group_id: string | null; + total: number | null; + paid_at: string | null; tax_amount: number | null; tax_snapshot: string | null; tax_breakdown: string | null; @@ -929,9 +1013,14 @@ export function computePeriodAggregates( ? (db .prepare( ` - SELECT b.* + SELECT b.id, b.order_id, b.split_group_id, b.total, b.paid_at, b.tax_amount, b.tax_snapshot, b.tax_breakdown FROM bills b - WHERE b.paid_at IS NOT NULL + LEFT JOIN orders o ON o.id = b.order_id + WHERE b.payment_details IS NOT NULL + AND ( + NULLIF(TRIM(b.paid_at), '') IS NOT NULL + OR (o.id IS NOT NULL AND COALESCE(o.status, '') != 'cancelled') + ) AND EXISTS ( SELECT 1 FROM json_each(CASE @@ -940,30 +1029,39 @@ export function computePeriodAggregates( ELSE '[]' END) je WHERE json_type(je.value) = 'object' AND ( - CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) = ? + ( + NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL + AND CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) = ? + ) OR ( - json_extract(je.value, '$.cash_session_id') IS NULL + NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NULL + AND ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} >= datetime(?) + AND ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} < datetime(?) 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(?) + ( + SELECT cs.id + FROM cash_sessions cs + WHERE ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} >= datetime(cs.opened_at) + AND ( + cs.closed_at IS NULL + OR ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} < datetime(cs.closed_at) + ) + ORDER BY datetime(cs.opened_at) DESC, cs.id DESC + LIMIT 1 + ), + ? + ) = ? ) ) ) - ORDER BY b.paid_at, b.id + ORDER BY COALESCE(b.paid_at, b.created_at), b.id `, ) - .all(session.id, session.opened_at, end) as TaxBillRow[]) + .all(session.id, session.opened_at, end, NO_CASH_SESSION_ID, session.id) as TaxBillRow[]) : (db .prepare( ` - SELECT b.* + SELECT b.id, b.order_id, b.split_group_id, b.total, b.paid_at, b.tax_amount, b.tax_snapshot, b.tax_breakdown FROM bills b WHERE b.paid_at >= ? AND b.paid_at < ? ORDER BY b.paid_at, b.id @@ -977,7 +1075,171 @@ export function computePeriodAggregates( tax_breakdown: bill.tax_breakdown, items: orders.get(Number(bill.id))?.items || [], })); - const taxComponents = aggregateTaxComponents(taxDocuments); + let taxComponents: DisplayTaxComponent[]; + if (!session) { + taxComponents = aggregateTaxComponents(taxDocuments); + } else { + const billPaymentLinesStmt = db.prepare( + ` + WITH lines AS ( + SELECT + CAST(je.key AS INTEGER) AS line_index, + json_extract(je.value, '$.amount') AS raw_amount, + NULLIF(json_extract(je.value, '$.cash_session_id'), '') AS raw_session_id, + ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} 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.id = ? AND json_type(je.value) = 'object' + ) + SELECT + raw_amount, + CASE + WHEN raw_session_id IS NOT NULL THEN + CASE WHEN CAST(raw_session_id AS INTEGER) = ? THEN 1 ELSE 0 END + WHEN line_time >= datetime(?) AND line_time < datetime(?) THEN + CASE + WHEN COALESCE( + ( + SELECT cs.id + FROM cash_sessions cs + WHERE line_time >= datetime(cs.opened_at) + AND (cs.closed_at IS NULL OR line_time < datetime(cs.closed_at)) + ORDER BY datetime(cs.opened_at) DESC, cs.id DESC + LIMIT 1 + ), + ? + ) = ? THEN 1 + ELSE 0 + END + ELSE 0 + END AS owned_within_end + FROM lines + ORDER BY line_index ASC + `, + ); + const perBillComponents = bills.map((bill, billIndex) => { + const doc = taxDocuments[billIndex]; + const rawLines = billPaymentLinesStmt.all( + bill.id, + session.id, + session.opened_at, + end, + NO_CASH_SESSION_ID, + session.id, + ) as Array<{ raw_amount: unknown; owned_within_end: number }>; + + const paymentLines = rawLines.map((line) => { + let amountMinor = 0; + if (typeof line.raw_amount === 'number' || typeof line.raw_amount === 'string') { + try { + const dec = new Decimal(line.raw_amount); + if (dec.isFinite() && dec.gt(0)) { + amountMinor = dec + .times(minorFactor) + .toDecimalPlaces(0, Decimal.ROUND_HALF_UP) + .toNumber(); + } + } catch { + amountMinor = 0; + } + } + return { + amountMinor, + ownedWithinEnd: Number(line.owned_within_end) === 1, + }; + }); + + const ownedWithinEndLines = paymentLines.filter((line) => line.ownedWithinEnd); + if (ownedWithinEndLines.length === 0) return []; + + const sumAllLineMinors = paymentLines.reduce((sum, line) => sum + line.amountMinor, 0); + let billTotalFromCol = 0; + if (bill.total !== null && bill.total !== undefined) { + try { + const totalDec = new Decimal(bill.total); + if (totalDec.isFinite() && totalDec.gt(0)) { + billTotalFromCol = totalDec + .times(minorFactor) + .toDecimalPlaces(0, Decimal.ROUND_HALF_UP) + .toNumber(); + } + } catch { + billTotalFromCol = 0; + } + } + const isSettled = bill.paid_at !== null && String(bill.paid_at).trim() !== ''; + const billTotalMinor = isSettled + ? sumAllLineMinors + : Math.max(billTotalFromCol, sumAllLineMinors); + const ownedMinorSum = ownedWithinEndLines.reduce((sum, line) => sum + line.amountMinor, 0); + + if ( + paymentLines.length > 0 && + ownedWithinEndLines.length === paymentLines.length && + (isSettled || (billTotalMinor > 0 && ownedMinorSum >= billTotalMinor)) + ) { + return resolveTaxComponents(doc); + } + + const docComponents = resolveTaxComponents(doc); + if (docComponents.length === 0) return []; + + const { compMinors, totalMinor } = roundComponentsToMinorUnits(docComponents, minorFactor); + let remainingPoolMinor = billTotalMinor; + let remainingCompMinors = [...compMinors]; + let remainingTotalMinor = totalMinor; + const sessionCompMinors = new Array(docComponents.length).fill(0); + + for (const line of paymentLines) { + const lineMinor = line.amountMinor; + let lineShares: number[]; + if (lineMinor <= 0 && remainingPoolMinor > 0) { + lineShares = new Array(docComponents.length).fill(0); + } else if (remainingPoolMinor - lineMinor <= 0) { + lineShares = [...remainingCompMinors]; + remainingCompMinors = new Array(docComponents.length).fill(0); + remainingTotalMinor = 0; + remainingPoolMinor = 0; + } else { + const pairWeights = [lineMinor, remainingPoolMinor - lineMinor]; + const entries = remainingCompMinors.map((minor) => ({ + allocations: allocateSignedMinorUnits(minor, pairWeights), + })); + if (entries.length > 1) { + const targetPair = allocateSignedMinorUnits(remainingTotalMinor, pairWeights); + reconcileSnapshotAllocations(entries, targetPair); + } + lineShares = entries.map((entry) => entry.allocations[0]); + remainingCompMinors = entries.map((entry) => entry.allocations[1]); + remainingTotalMinor = remainingCompMinors.reduce((sum, minor) => sum + minor, 0); + remainingPoolMinor -= lineMinor; + } + + if (line.ownedWithinEnd) { + for (let i = 0; i < docComponents.length; i++) { + sessionCompMinors[i] += lineShares[i]; + } + } + } + + const allocated: DisplayTaxComponent[] = []; + for (let i = 0; i < docComponents.length; i++) { + const minorShare = sessionCompMinors[i]; + if (minorShare === 0 && compMinors[i] !== 0) continue; + allocated.push({ + title: docComponents[i].title, + rate: docComponents[i].rate, + amount: new Decimal(minorShare).dividedBy(minorFactor).toDecimalPlaces(6).toNumber(), + }); + } + return allocated; + }); + taxComponents = mergeDisplayTaxComponents(perBillComponents); + } const grossCollectedCents = Math.round(Number(billRow.gross_collected || 0) * minorFactor); const refundedCents = Number(refundRow.refunded_cents || 0); diff --git a/main/routes/cash-sessions.ts b/main/routes/cash-sessions.ts index 808a32c92..4a122639e 100644 --- a/main/routes/cash-sessions.ts +++ b/main/routes/cash-sessions.ts @@ -33,10 +33,8 @@ import { type CashSessionRow, } from '../services/shift-session-gate'; import { - computePeriodAggregates, + computeSessionAggregates, sessionExpectedCash, - sessionFinancialTotals, - listCashDrawerMovementsForSession, tenantTimezone, cashOperationFailurePayload, } from './cash-closures'; @@ -117,25 +115,8 @@ function closeSessionTxn( counted_cash_cents: number; } { const closedAt = now(); - const sessionMovements = listCashDrawerMovementsForSession( - db, - session.id, - session.opened_at, - closedAt, - false, - ); - const aggregates = { - ...computePeriodAggregates( - db, - session.opened_at, - closedAt, - sessionMovements, - Number(session.opening_float_cents || 0), - session, - ), - ...sessionFinancialTotals(db, session, closedAt), - }; - const expectedCashCents = sessionExpectedCash(db, session, closedAt, sessionMovements); + const aggregates = computeSessionAggregates(db, session, closedAt); + const expectedCashCents = sessionExpectedCash(db, session, closedAt, aggregates.cashMovements); const varianceCents = countedCashCents - expectedCashCents; let zNumber: number; try { diff --git a/main/routes/reports.ts b/main/routes/reports.ts index c1e883429..593fbe988 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -32,12 +32,10 @@ import type { CashSessionRow } from '../services/shift-session-gate'; import type { AuthedRequest } from './cash-sessions'; import { computeDayAggregates, - computePeriodAggregates, + computeSessionAggregates, expectedCashFromAggregates, - listCashDrawerMovementsForSession, paymentMethodBreakdown, sessionExpectedCash, - sessionFinancialTotals, } from './cash-closures'; import { getCurrencyFractionDigits } from '../countries'; import { roundCurrencyCost } from '../services/supplies'; @@ -1265,38 +1263,54 @@ router.get('/insights', requirePermission('reports.view'), (req: Request, res: R // opening_float_cents. Consumers must not compare them directly; X is the // live drawer expectation, Z is the point-in-time snapshot that bakes in the // float. +function resolveRequestedCashSession( + db: ReturnType, + req: Request, + res: Response, +): CashSessionRow | null | 'responded' { + if (req.query.session_id === undefined || String(req.query.session_id).trim() === '') { + return null; + } + const sessionId = Number(req.query.session_id); + if (!Number.isInteger(sessionId) || sessionId <= 0) { + res.status(400).json({ error: 'Invalid session_id', code: 'session_id_invalid' }); + return 'responded'; + } + 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) { + res.status(404).json({ error: 'Shift not found', code: 'session_unavailable' }); + return 'responded'; + } + 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 + ) { + res.status(403).json({ + error: "Only owners and managers can view another staff member's shift report", + code: 'shift_access_forbidden', + }); + return 'responded'; + } + return session; +} + router.get('/x-report', requirePermission('reports.view'), (req: Request, res: Response) => { try { 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 session = resolveRequestedCashSession(db, req, res); + if (session === 'responded') return; + if (session) { const shiftDate = sessionBusinessDate(db, session); const sessionClosedRow = session.closure_id != null @@ -1322,26 +1336,14 @@ router.get('/x-report', requirePermission('reports.view'), (req: Request, res: R opened_at: s.opened_at, })); const sessionEnd = session.closed_at || now(); - const sessionMovements = listCashDrawerMovementsForSession( + const openingFloatCents = Number(session.opening_float_cents || 0); + const sessionAggregates = computeSessionAggregates(db, session, sessionEnd); + const totalExpectedCashCents = sessionExpectedCash( db, - session.id, - session.opened_at, + session, sessionEnd, - false, + sessionAggregates.cashMovements, ); - 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, @@ -1584,7 +1586,6 @@ router.get( requirePermission('reports.view'), async (req: Request, res: Response) => { try { - const date = reportDate(req.query.date, reportToday()); const format = req.query.format === 'csv' ? 'csv' @@ -1596,40 +1597,94 @@ router.get( if (!format) return res.status(400).json({ error: 'format must be xlsx, csv, or html' }); const db = getDatabase(); + const session = resolveRequestedCashSession(db, req, res); + if (session === 'responded') return; + const minorFactor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); - const [periodStart, periodEnd] = reportDayBounds(date); - const aggregates = computeDayAggregates(db, date); - const report: XReportExport = { - businessDate: date, - periodStart, - periodEnd, - grossSales: aggregates.grossCollectedCents / minorFactor, - refunds: aggregates.refundedCents / minorFactor, - netCollections: aggregates.netCollectedCents / minorFactor, - billCount: aggregates.billCount, - expectedCash: expectedCashFromAggregates(aggregates) / minorFactor, - openingFloat: aggregates.openingFloatCents / minorFactor, - payIn: aggregates.payInCents / minorFactor, - payOut: aggregates.payOutCents / minorFactor, - safeDrops: aggregates.safeDropCents / minorFactor, - paymentMethods: aggregates.paymentMethods.map((row) => ({ - method: row.method, - count: row.count, - total: row.total_cents / minorFactor, - })), - staffSales: aggregates.staffSales.map((row) => ({ - name: row.name, - role: row.role, - orderCount: row.orderCount, - revenue: row.revenue_cents / minorFactor, - })), - taxComponents: aggregates.taxComponents.map((comp) => ({ - title: comp.title, - rate: comp.rate, - amount: comp.amount, - })), - }; - const filename = `x-report-${date}.${format}`; + let report: XReportExport; + let filename: string; + if (session) { + const shiftDate = sessionBusinessDate(db, session); + const sessionEnd = session.closed_at || now(); + const openingFloatCents = Number(session.opening_float_cents || 0); + const sessionAggregates = computeSessionAggregates(db, session, sessionEnd); + const totalExpectedCashCents = sessionExpectedCash( + db, + session, + sessionEnd, + sessionAggregates.cashMovements, + ); + report = { + businessDate: shiftDate, + periodStart: session.opened_at, + periodEnd: sessionEnd, + grossSales: sessionAggregates.grossCollectedCents / minorFactor, + refunds: sessionAggregates.refundedCents / minorFactor, + netCollections: sessionAggregates.netCollectedCents / minorFactor, + billCount: sessionAggregates.billCount, + expectedCash: totalExpectedCashCents / minorFactor, + openingFloat: openingFloatCents / minorFactor, + payIn: sessionAggregates.payInCents / minorFactor, + payOut: sessionAggregates.payOutCents / minorFactor, + safeDrops: sessionAggregates.safeDropCents / minorFactor, + paymentMethods: sessionAggregates.paymentMethods.map((row) => ({ + method: row.method, + count: row.count, + total: row.total_cents / minorFactor, + })), + staffSales: sessionAggregates.staffSales.map((row) => ({ + name: row.name, + role: row.role, + orderCount: row.orderCount, + revenue: row.revenue_cents / minorFactor, + })), + taxComponents: sessionAggregates.taxComponents.map((comp) => ({ + title: comp.title, + rate: comp.rate, + amount: comp.amount, + })), + session: { + id: session.id, + openedByName: session.opened_by_name || session.opened_by, + }, + }; + filename = `x-report-${shiftDate}-shift-${session.id}.${format}`; + } else { + const date = reportDate(req.query.date, reportToday()); + const [periodStart, periodEnd] = reportDayBounds(date); + const aggregates = computeDayAggregates(db, date); + report = { + businessDate: date, + periodStart, + periodEnd, + grossSales: aggregates.grossCollectedCents / minorFactor, + refunds: aggregates.refundedCents / minorFactor, + netCollections: aggregates.netCollectedCents / minorFactor, + billCount: aggregates.billCount, + expectedCash: expectedCashFromAggregates(aggregates) / minorFactor, + openingFloat: aggregates.openingFloatCents / minorFactor, + payIn: aggregates.payInCents / minorFactor, + payOut: aggregates.payOutCents / minorFactor, + safeDrops: aggregates.safeDropCents / minorFactor, + paymentMethods: aggregates.paymentMethods.map((row) => ({ + method: row.method, + count: row.count, + total: row.total_cents / minorFactor, + })), + staffSales: aggregates.staffSales.map((row) => ({ + name: row.name, + role: row.role, + orderCount: row.orderCount, + revenue: row.revenue_cents / minorFactor, + })), + taxComponents: aggregates.taxComponents.map((comp) => ({ + title: comp.title, + rate: comp.rate, + amount: comp.amount, + })), + }; + filename = `x-report-${date}.${format}`; + } if (format === 'xlsx') { res.setHeader( 'Content-Type', diff --git a/main/services/cash-close-export.ts b/main/services/cash-close-export.ts index 76b4251d2..5c3ec5ad5 100644 --- a/main/services/cash-close-export.ts +++ b/main/services/cash-close-export.ts @@ -45,7 +45,14 @@ interface CashCloseExportBase { taxComponents?: CashCloseTaxComponentExportRow[]; } -export type XReportExport = CashCloseExportBase; +export interface CashCloseShiftExportMeta { + id: number; + openedByName: string; +} + +export interface XReportExport extends CashCloseExportBase { + session?: CashCloseShiftExportMeta | null; +} export interface ZReportExport extends CashCloseExportBase { zNumber: number; @@ -148,7 +155,12 @@ function formatCountWithExact(count: number, resolved: Required(${escapeHtml(exact)})`; } -function xReportMetrics(report: XReportExport): ReportMetric[] { +function formatShiftScope(session: CashCloseShiftExportMeta): string { + const staffName = session.openedByName.trim(); + return staffName ? `Shift #${session.id} - ${staffName}` : `Shift #${session.id}`; +} + +function baseReportMetrics(report: CashCloseExportBase): ReportMetric[] { return [ ['Business Date', report.businessDate], ['Period Start', report.periodStart], @@ -165,9 +177,15 @@ function xReportMetrics(report: XReportExport): ReportMetric[] { ]; } +function xReportMetrics(report: XReportExport): ReportMetric[] { + const base = baseReportMetrics(report); + if (!report.session) return base; + return [base[0], ['Shift', formatShiftScope(report.session)], ...base.slice(1)]; +} + function zReportMetrics(report: ZReportExport): ReportMetric[] { return [ - ...xReportMetrics(report), + ...baseReportMetrics(report), ['Z Number', report.zNumber], ['Closed At', report.closedAt], ['Closed By', report.closedBy], @@ -232,14 +250,17 @@ async function serializeXlsx( function serializeHtml( kind: 'x' | 'z', metrics: ReportMetric[], - report: CashCloseExportBase & Partial, + report: CashCloseExportBase & Partial & Partial, options?: CashCloseHtmlOptions, ): string { const resolved = resolveHtmlOptions(options); + const shiftScopeText = kind === 'x' && report.session ? formatShiftScope(report.session) : null; const titleText = kind === 'z' && typeof report.zNumber === 'number' ? `Z Report #${report.zNumber} — ${report.businessDate}` - : `X Report — ${report.businessDate}`; + : shiftScopeText + ? `X Report (${shiftScopeText}) - ${report.businessDate}` + : `X Report — ${report.businessDate}`; const summaryRowsHtml = metrics .map(([metric, value]) => { @@ -431,7 +452,11 @@ function serializeHtml(

${escapeHtml(resolved.businessName)}

${escapeHtml(titleText)}

- Business Date: ${escapeHtml(report.businessDate)} + Business Date: ${escapeHtml(report.businessDate)}${ + shiftScopeText + ? `\n · Shift: ${escapeHtml(shiftScopeText)}` + : '' + } · Period: ${escapeHtml(report.periodStart)} to ${escapeHtml(report.periodEnd)} · Currency: ${escapeHtml(resolved.currency)}

diff --git a/main/services/tax-components.ts b/main/services/tax-components.ts index 6e1320e62..efb567090 100644 --- a/main/services/tax-components.ts +++ b/main/services/tax-components.ts @@ -40,8 +40,7 @@ function hasSplitAllocatedSnapshot(value: unknown): boolean { if (Array.isArray(parsed)) return parsed.some(hasSplitAllocatedSnapshot); if (!parsed || typeof parsed !== 'object') return false; const snapshot = parsed as Record; - return snapshot.splitAllocation === SPLIT_TAX_SNAPSHOT_VERSION - && Array.isArray(snapshot.lines); + return snapshot.splitAllocation === SPLIT_TAX_SNAPSHOT_VERSION && Array.isArray(snapshot.lines); } function decimalOrNull(value: unknown): Decimal | null { @@ -145,15 +144,16 @@ function reconcileTotal( const target = decimalOrNull(targetValue); if (!target) return components; if (components.length === 0) { - return target.isZero() ? [] : [{ title: 'Tax', rate: null, amount: target.toDecimalPlaces(6).toNumber() }]; + return target.isZero() + ? [] + : [{ title: 'Tax', rate: null, amount: target.toDecimalPlaces(6).toNumber() }]; } - const current = components.reduce( - (sum, component) => sum.plus(component.amount), - new Decimal(0), - ); + const current = components.reduce((sum, component) => sum.plus(component.amount), new Decimal(0)); if (current.equals(target)) return components; if (current.isZero()) { - return target.isZero() ? components : [{ title: 'Tax', rate: null, amount: target.toDecimalPlaces(6).toNumber() }]; + return target.isZero() + ? components + : [{ title: 'Tax', rate: null, amount: target.toDecimalPlaces(6).toNumber() }]; } const ratio = target.dividedBy(current); @@ -165,19 +165,20 @@ function reconcileTotal( (sum, component) => sum.plus(component.amount), new Decimal(0), ); - reconciled[reconciled.length - 1].amount = new Decimal( - reconciled[reconciled.length - 1].amount, - ).plus(target.minus(allocated)).toDecimalPlaces(6).toNumber(); + reconciled[reconciled.length - 1].amount = new Decimal(reconciled[reconciled.length - 1].amount) + .plus(target.minus(allocated)) + .toDecimalPlaces(6) + .toNumber(); return reconciled; } function legacyItemComponents(document: TaxDocument): DecimalTaxComponent[] { - return (document.items || []).filter( - (item) => !isTerminalItemStatus(item.status), - ).flatMap((item) => { - const snapshot = flattenSnapshots(item.tax_snapshot); - return snapshot.present ? [] : flattenLegacyBreakdown(item.tax_breakdown); - }); + return (document.items || []) + .filter((item) => !isTerminalItemStatus(item.status)) + .flatMap((item) => { + const snapshot = flattenSnapshots(item.tax_snapshot); + return snapshot.present ? [] : flattenLegacyBreakdown(item.tax_breakdown); + }); } function documentLegacyComponents( @@ -192,10 +193,14 @@ function documentLegacyComponents( return flattenLegacyBreakdown(document.tax_breakdown).flatMap((component) => { const key = `${component.title}\u0000${component.rate ?? ''}`; const itemAmount = remainingItems.get(key) || new Decimal(0); - const sameSign = itemAmount.isZero() || component.amount.isZero() - || itemAmount.isPositive() === component.amount.isPositive(); + const sameSign = + itemAmount.isZero() || + component.amount.isZero() || + itemAmount.isPositive() === component.amount.isPositive(); const consumed = sameSign - ? Decimal.min(itemAmount.abs(), component.amount.abs()).mul(component.amount.isNegative() ? -1 : 1) + ? Decimal.min(itemAmount.abs(), component.amount.abs()).mul( + component.amount.isNegative() ? -1 : 1, + ) : new Decimal(0); const residual = component.amount.minus(consumed); remainingItems.set(key, itemAmount.minus(consumed)); @@ -209,7 +214,9 @@ export function resolveTaxComponents(document: TaxDocument): DisplayTaxComponent if (hasSplitAllocatedSnapshot(document.tax_snapshot)) { const splitSnapshot = flattenSnapshots(document.tax_snapshot); if (splitSnapshot.present) { - const hasItemSnapshot = (document.items || []).some((item) => flattenSnapshots(item.tax_snapshot).present); + const hasItemSnapshot = (document.items || []).some( + (item) => flattenSnapshots(item.tax_snapshot).present, + ); const snapshotComponents = hasItemSnapshot ? splitSnapshot.components : splitSnapshot.components.filter((component) => !component.amount.isZero()); @@ -222,10 +229,12 @@ export function resolveTaxComponents(document: TaxDocument): DisplayTaxComponent (sum, component) => sum.plus(component.amount), new Decimal(0), ); - const needsDocumentResidual = !target - || (!target.isZero() && (target.isNegative() - ? representedTotal.comparedTo(target) > 0 - : representedTotal.comparedTo(target) < 0)); + const needsDocumentResidual = + !target || + (!target.isZero() && + (target.isNegative() + ? representedTotal.comparedTo(target) > 0 + : representedTotal.comparedTo(target) < 0)); return reconcileTotal( mergeComponents([ ...represented.map((component) => ({ @@ -233,18 +242,14 @@ export function resolveTaxComponents(document: TaxDocument): DisplayTaxComponent rate: component.rate === null ? null : new Decimal(component.rate).toString(), amount: new Decimal(component.amount), })), - ...(needsDocumentResidual - ? documentLegacyComponents(document, snapshotComponents) - : []), + ...(needsDocumentResidual ? documentLegacyComponents(document, snapshotComponents) : []), ]), document.tax_amount, ); } } - const activeItems = document.items?.filter( - (item) => !isTerminalItemStatus(item.status), - ); + const activeItems = document.items?.filter((item) => !isTerminalItemStatus(item.status)); if (activeItems && activeItems.length > 0) { const components: DecimalTaxComponent[] = []; @@ -273,35 +278,39 @@ export function resolveTaxComponents(document: TaxDocument): DisplayTaxComponent ); const documentTotal = decimalOrNull(document.tax_amount); const itemTarget = documentTotal ? documentTotal.minus(chargeTotal).toString() : undefined; - return mergeComponents([ - ...reconcileTotal(mergeComponents(components), itemTarget), - ...chargeComponents, - ...documentLegacyComponents(document, [ - ...itemSnapshotComponents, - ...chargeSnapshot.components, - ]), - ].map((component) => ({ - title: component.title, - rate: component.rate === null ? null : new Decimal(component.rate).toString(), - amount: new Decimal(component.amount), - }))); + return mergeComponents( + [ + ...reconcileTotal(mergeComponents(components), itemTarget), + ...chargeComponents, + ...documentLegacyComponents(document, [ + ...itemSnapshotComponents, + ...chargeSnapshot.components, + ]), + ].map((component) => ({ + title: component.title, + rate: component.rate === null ? null : new Decimal(component.rate).toString(), + amount: new Decimal(component.amount), + })), + ); } const legacyItems = legacyItemComponents(document); - const documentResidual = hasSnapshotEvidence - && ((document.tax_amount !== undefined && document.tax_amount !== null) || legacyItems.length > 0) - ? documentLegacyComponents(document, itemSnapshotComponents) - : []; - return reconcileTotal(mergeComponents([...components, ...documentResidual]), document.tax_amount); + const documentResidual = + hasSnapshotEvidence && + ((document.tax_amount !== undefined && document.tax_amount !== null) || + legacyItems.length > 0) + ? documentLegacyComponents(document, itemSnapshotComponents) + : []; + return reconcileTotal( + mergeComponents([...components, ...documentResidual]), + document.tax_amount, + ); } } const snapshot = flattenSnapshots(document.tax_snapshot); const components = mergeComponents( snapshot.present - ? [ - ...snapshot.components, - ...documentLegacyComponents(document, snapshot.components), - ] + ? [...snapshot.components, ...documentLegacyComponents(document, snapshot.components)] : flattenLegacyBreakdown(document.tax_breakdown), ); return reconcileTotal( @@ -310,12 +319,10 @@ export function resolveTaxComponents(document: TaxDocument): DisplayTaxComponent ); } -export function aggregateTaxComponents( - documents: TaxDocument[], -): DisplayTaxComponent[] { +export function mergeDisplayTaxComponents(lists: DisplayTaxComponent[][]): DisplayTaxComponent[] { const components: DecimalTaxComponent[] = []; - for (const document of documents) { - for (const component of resolveTaxComponents(document)) { + for (const list of lists) { + for (const component of list) { components.push({ title: component.title, rate: component.rate === null ? null : new Decimal(component.rate).toString(), @@ -325,3 +332,7 @@ export function aggregateTaxComponents( } return mergeComponents(components); } + +export function aggregateTaxComponents(documents: TaxDocument[]): DisplayTaxComponent[] { + return mergeDisplayTaxComponents(documents.map((document) => resolveTaxComponents(document))); +} diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index b861b943d..a9bc79bbb 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -3344,6 +3344,1514 @@ async function main() { 'v3.14.0 combo: Z-report CSV export reconciles with corrected Card payment and expected cash', ); }); + + console.log( + 'Section 13: proportional cross-shift bill tax attribution + shift-scoped X-report exports', + ); + await withFixedClock('2026-11-15T10:00:00.000Z', async () => { + const priorTaxRate = getSettingValue('tax_rate'); + const priorTaxRules = getSettingValue('tax_rules'); + try { + const openTaxShift1 = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 5000 }); + const openTaxShift2 = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 3000 }); + assert( + openTaxShift1.status === 200 && openTaxShift2.status === 200, + 'Section 13: Cashier A and Cashier B open concurrent shifts on 2026-11-15 (200)', + ); + const taxShift1Id = Number(openTaxShift1.body?.id); + const taxShift2Id = Number(openTaxShift2.body?.id); + + const ts = '2026-11-15T10:05:00.000Z'; + // Bill 1: subtotal=100, VAT 10% = 10, total=110, split 66.00 (Shift 1 cash) + 44.00 (Shift 2 card) + const order1Insert = db + .prepare( + `INSERT INTO orders (order_number, type, status, user_id, subtotal, tax_amount, total, created_at, updated_at) + VALUES ('ORD-CROSS-TAX-1', 'takeaway', 'ready', 'cashier-sess', 100, 10, 110, ?, ?)`, + ) + .run(ts, ts); + const order1Id = Number(order1Insert.lastInsertRowid); + const bill1Snapshot = JSON.stringify({ + lines: [ + { + taxableBase: 100, + components: [{ label: 'VAT', rate: 10, amount: 10 }], + }, + ], + }); + db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, status, created_at, updated_at + ) VALUES (?, 'prod-v314', 'Taxed Platter', 1, 100, 100, 10, 110, ?, 'ready', ?, ?)`, + ).run(order1Id, bill1Snapshot, ts, ts); + const bill1Insert = db + .prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, total, + paid_amount, balance, payment_status, payment_details, created_at, updated_at + ) VALUES ('B-CROSS-TAX-1', ?, 100, 10, ?, 110, 0, 110, 'unpaid', '[]', ?, ?)`, + ) + .run(order1Id, bill1Snapshot, ts, ts); + const bill1Id = Number(bill1Insert.lastInsertRowid); + + const pay1A = await request(app) + .post(`/api/bills/${bill1Id}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 66 }); + const pay1B = await request(app) + .post(`/api/bills/${bill1Id}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'card', amount: 44, transaction_id: 'TX-CROSS-TAX-1B' }); + assert( + pay1A.status === 200 && pay1B.status === 200, + 'Section 13: Bill 1 (subtotal=100, VAT 10%=10, total=110) paid across Shift 1 (66) and Shift 2 (44)', + ); + + // Bill 2: uneven split with minor-unit remainder (33.33 + 66.67 = 100.00) and two 5% components (5.00 + 5.00 = 10.00) + const order2Insert = db + .prepare( + `INSERT INTO orders (order_number, type, status, user_id, subtotal, tax_amount, total, created_at, updated_at) + VALUES ('ORD-CROSS-TAX-2', 'takeaway', 'ready', 'cashier2-sess', 90, 10, 100, ?, ?)`, + ) + .run(ts, ts); + const order2Id = Number(order2Insert.lastInsertRowid); + const bill2Snapshot = JSON.stringify({ + lines: [ + { + taxableBase: 90, + components: [ + { label: 'CGST', rate: 5, amount: 5 }, + { label: 'SGST', rate: 5, amount: 5 }, + ], + }, + ], + }); + db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, status, created_at, updated_at + ) VALUES (?, 'prod-v314', 'Dual Tax Platter', 1, 90, 90, 10, 100, ?, 'ready', ?, ?)`, + ).run(order2Id, bill2Snapshot, ts, ts); + const bill2Insert = db + .prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, total, + paid_amount, balance, payment_status, payment_details, created_at, updated_at + ) VALUES ('B-CROSS-TAX-2', ?, 90, 10, ?, 100, 0, 100, 'unpaid', '[]', ?, ?)`, + ) + .run(order2Id, bill2Snapshot, ts, ts); + const bill2Id = Number(bill2Insert.lastInsertRowid); + + const pay2A = await request(app) + .post(`/api/bills/${bill2Id}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 33.33 }); + const pay2B = await request(app) + .post(`/api/bills/${bill2Id}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 66.67 }); + assert( + pay2A.status === 200 && pay2B.status === 200, + 'Section 13: Bill 2 (tax=10.00 across CGST 5% + SGST 5%, total=100.00) paid 33.33 in Shift 1 and 66.67 in Shift 2', + ); + + // Change live tax settings after settlement to verify reports never recompute from live tax rules + db.prepare( + `INSERT INTO settings (key, value, updated_at) VALUES ('tax_rate', '25', ?) + ON CONFLICT(key) DO UPDATE SET value = '25', updated_at = excluded.updated_at`, + ).run(ts); + db.prepare( + `INSERT INTO settings (key, value, updated_at) VALUES ('tax_rules', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + ).run(JSON.stringify([{ id: 'live-25', name: 'Live Changed Tax', rate: 25 }]), ts); + + const findTaxAmt = ( + comps: Array<{ title: string; rate: number | null; amount: number }> | undefined, + title: string, + rate: number, + ): number => { + const match = (comps || []).find((c) => c.title === title && c.rate === rate); + return match ? Number(match.amount) : 0; + }; + const sumTaxAmts = ( + comps: Array<{ title: string; rate: number | null; amount: number }> | undefined, + ): number => + Math.round((comps || []).reduce((sum, c) => sum + Number(c.amount), 0) * 100) / 100; + + const xShift1 = await request(app) + .get(`/api/reports/x-report?date=2026-11-15&session_id=${taxShift1Id}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xShift2 = await request(app) + .get(`/api/reports/x-report?date=2026-11-15&session_id=${taxShift2Id}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xDaily = await request(app) + .get('/api/reports/x-report?date=2026-11-15') + .set('Authorization', `Bearer ${s11ManagerToken}`); + + const s1Tax = xShift1.body?.xReport?.taxComponents; + const s2Tax = xShift2.body?.xReport?.taxComponents; + const dayTax = xDaily.body?.xReport?.taxComponents; + + assert( + xShift1.status === 200 && + xShift2.status === 200 && + xDaily.status === 200 && + findTaxAmt(s1Tax, 'VAT', 10) === 6 && + findTaxAmt(s2Tax, 'VAT', 10) === 4 && + findTaxAmt(dayTax, 'VAT', 10) === 10 && + Math.round((findTaxAmt(s1Tax, 'CGST', 5) + findTaxAmt(s2Tax, 'CGST', 5)) * 100) / + 100 === + 5 && + Math.round((findTaxAmt(s1Tax, 'SGST', 5) + findTaxAmt(s2Tax, 'SGST', 5)) * 100) / + 100 === + 5 && + Math.round((findTaxAmt(s1Tax, 'CGST', 5) + findTaxAmt(s1Tax, 'SGST', 5)) * 100) / + 100 === + 3.33 && + Math.round((findTaxAmt(s2Tax, 'CGST', 5) + findTaxAmt(s2Tax, 'SGST', 5)) * 100) / + 100 === + 6.67 && + sumTaxAmts(s1Tax) === 9.33 && + sumTaxAmts(s2Tax) === 10.67 && + sumTaxAmts(dayTax) === 20 && + Math.round((sumTaxAmts(s1Tax) + sumTaxAmts(s2Tax)) * 100) / 100 === + sumTaxAmts(dayTax) && + !(s1Tax || []).some((c: { title: string }) => c.title === 'Live Changed Tax'), + `Section 13: live X-report allocates historical tax proportionally across shifts without duplication (s1=${JSON.stringify(s1Tax)}, s2=${JSON.stringify(s2Tax)}, day=${JSON.stringify(dayTax)})`, + ); + + // Verify GET /api/reports/x-report/export validation, permissions, and shift-scoped content + const badExportId = await request(app) + .get('/api/reports/x-report/export?date=2026-11-15&format=csv&session_id=abc') + .set('Authorization', `Bearer ${s11ManagerToken}`); + const zeroExportId = await request(app) + .get('/api/reports/x-report/export?date=2026-11-15&format=csv&session_id=0') + .set('Authorization', `Bearer ${s11ManagerToken}`); + const missingExportId = await request(app) + .get('/api/reports/x-report/export?date=2026-11-15&format=csv&session_id=999999') + .set('Authorization', `Bearer ${s11ManagerToken}`); + assert( + badExportId.status === 400 && + badExportId.body?.code === 'session_id_invalid' && + zeroExportId.status === 400 && + zeroExportId.body?.code === 'session_id_invalid' && + missingExportId.status === 404 && + missingExportId.body?.code === 'session_unavailable', + 'Section 13: X-report export validates session_id (400 session_id_invalid, 404 session_unavailable)', + ); + + 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(ts); + const ownShiftExport = await request(app) + .get( + `/api/reports/x-report/export?date=2026-11-15&format=csv&session_id=${taxShift1Id}`, + ) + .set('Authorization', `Bearer ${s11CashierAToken}`); + const crossShiftExportForbidden = await request(app) + .get( + `/api/reports/x-report/export?date=2026-11-15&format=csv&session_id=${taxShift2Id}`, + ) + .set('Authorization', `Bearer ${s11CashierAToken}`); + db.prepare( + `DELETE FROM user_permission_overrides WHERE user_id = 'cashier-sess' AND permission_id = 'reports.view'`, + ).run(); + + assert( + ownShiftExport.status === 200 && + String(ownShiftExport.headers['content-disposition'] || '').includes( + `filename="x-report-2026-11-15-shift-${taxShift1Id}.csv"`, + ) && + ownShiftExport.text.includes(`summary,Shift,,,,Shift #${taxShift1Id} - `) && + ownShiftExport.text.includes('summary,Gross Sales,,,,99.33') && + ownShiftExport.text.includes('summary,Opening Float,,,,50') && + ownShiftExport.text.includes('summary,Expected Cash in Drawer,,,,149.33') && + !ownShiftExport.text.includes('summary,Gross Sales,,,,210'), + `Section 13: Cashier A exports own shift CSV with shift label, shift filename, and Shift 1 figures`, + ); + assert( + crossShiftExportForbidden.status === 403 && + crossShiftExportForbidden.body?.code === 'shift_access_forbidden', + `Section 13: Cashier A cannot export Cashier B shift X report (403 shift_access_forbidden, got ${crossShiftExportForbidden.status})`, + ); + + const mgrShift2HtmlExport = await request(app) + .get( + `/api/reports/x-report/export?date=2026-11-15&format=html&session_id=${taxShift2Id}`, + ) + .set('Authorization', `Bearer ${s11ManagerToken}`); + assert( + mgrShift2HtmlExport.status === 200 && + String(mgrShift2HtmlExport.headers['content-disposition'] || '').includes( + `filename="x-report-2026-11-15-shift-${taxShift2Id}.html"`, + ) && + mgrShift2HtmlExport.text.includes(`Shift #${taxShift2Id} - `) && + mgrShift2HtmlExport.text.includes('data-metric="Shift"') && + mgrShift2HtmlExport.text.includes('data-metric="Gross Sales" data-value="110.67"') && + mgrShift2HtmlExport.text.includes( + 'data-section="tax" data-title="VAT" data-rate="10" data-amount="4"', + ), + 'Section 13: Manager exports Shift 2 HTML with shift header, shift filename, Shift 2 totals, and proportional tax breakdown', + ); + + // Close both shifts and day, then verify persisted Z tax_components_json on both session closures and day closure + const closeShift1 = await request(app) + .post(`/api/cash-sessions/${taxShift1Id}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 14933 }); + const closeShift2 = await request(app) + .post(`/api/cash-sessions/${taxShift2Id}/close`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ counted_cash_cents: 9667 }); + assert( + closeShift1.status === 200 && + closeShift1.body?.variance_cents === 0 && + closeShift2.status === 200 && + closeShift2.body?.variance_cents === 0, + 'Section 13: Shift 1 and Shift 2 close cleanly with 0 variance (200)', + ); + + const closeTaxDay = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-15', + opening_float_cents: 8000, + counted_cash_cents: 24600, + }); + assert( + closeTaxDay.status === 201 && closeTaxDay.body?.zReport?.variance_cents === 0, + `Section 13: day close on 2026-11-15 succeeds with 0 variance (got status=${closeTaxDay.status}, variance=${closeTaxDay.body?.zReport?.variance_cents})`, + ); + + const shift1ClosureRow = db + .prepare(`SELECT tax_components_json FROM cash_closures WHERE id = ?`) + .get(closeShift1.body.closure_id) as { tax_components_json: string }; + const shift2ClosureRow = db + .prepare(`SELECT tax_components_json FROM cash_closures WHERE id = ?`) + .get(closeShift2.body.closure_id) as { tax_components_json: string }; + const zShift1Tax = JSON.parse(shift1ClosureRow.tax_components_json); + const zShift2Tax = JSON.parse(shift2ClosureRow.tax_components_json); + const zDayTax = closeTaxDay.body?.zReport?.tax_components; + + assert( + JSON.stringify(zShift1Tax) === JSON.stringify(s1Tax) && + JSON.stringify(zShift2Tax) === JSON.stringify(s2Tax) && + findTaxAmt(zShift1Tax, 'VAT', 10) + findTaxAmt(zShift2Tax, 'VAT', 10) === + findTaxAmt(zDayTax, 'VAT', 10) && + Math.round( + (findTaxAmt(zShift1Tax, 'CGST', 5) + findTaxAmt(zShift2Tax, 'CGST', 5)) * 100, + ) / + 100 === + findTaxAmt(zDayTax, 'CGST', 5) && + Math.round( + (findTaxAmt(zShift1Tax, 'SGST', 5) + findTaxAmt(zShift2Tax, 'SGST', 5)) * 100, + ) / + 100 === + findTaxAmt(zDayTax, 'SGST', 5) && + Math.round((sumTaxAmts(zShift1Tax) + sumTaxAmts(zShift2Tax)) * 100) / 100 === + sumTaxAmts(zDayTax), + `Section 13: persisted Z tax_components_json on Shift 1 + Shift 2 equals daily Z tax with zero duplication`, + ); + } finally { + if (priorTaxRate === null) { + db.prepare(`DELETE FROM settings WHERE key = 'tax_rate'`).run(); + } else { + db.prepare(`UPDATE settings SET value = ? WHERE key = 'tax_rate'`).run(priorTaxRate); + } + if (priorTaxRules === null) { + db.prepare(`DELETE FROM settings WHERE key = 'tax_rules'`).run(); + } else { + db.prepare(`UPDATE settings SET value = ? WHERE key = 'tax_rules'`).run(priorTaxRules); + } + } + }); + + // Section 13b: partial payment carry-over where Shift A closes before Shift B settles + { + const seedTaxBill = ( + tag: string, + subtotal: number, + taxAmount: number, + total: number, + components: Array<{ label: string; rate: number; amount: number }>, + createdAt: string, + ): number => { + const orderIns = db + .prepare( + `INSERT INTO orders (order_number, type, status, user_id, subtotal, tax_amount, total, created_at, updated_at) + VALUES (?, 'takeaway', 'ready', 'cashier-sess', ?, ?, ?, ?, ?)`, + ) + .run(`ORD-${tag}`, subtotal, taxAmount, total, createdAt, createdAt); + const orderId = Number(orderIns.lastInsertRowid); + const snap = JSON.stringify({ + lines: [{ taxableBase: subtotal, components }], + }); + db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, status, created_at, updated_at + ) VALUES (?, 'prod-v314', ?, 1, ?, ?, ?, ?, ?, 'ready', ?, ?)`, + ).run( + orderId, + `Item ${tag}`, + subtotal, + subtotal, + taxAmount, + total, + snap, + createdAt, + createdAt, + ); + const billIns = db + .prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, total, + paid_amount, balance, payment_status, payment_details, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, 'unpaid', '[]', ?, ?)`, + ) + .run( + `B-${tag}`, + orderId, + subtotal, + taxAmount, + snap, + total, + total, + createdAt, + createdAt, + ); + return Number(billIns.lastInsertRowid); + }; + + const findCompAmt = ( + comps: Array<{ title: string; rate: number | null; amount: number }> | undefined, + title: string, + rate: number, + ): number => { + const match = (comps || []).find((c) => c.title === title && c.rate === rate); + return match ? Number(match.amount) : 0; + }; + const sumCompAmts = ( + comps: Array<{ title: string; rate: number | null; amount: number }> | undefined, + ): number => + Math.round((comps || []).reduce((sum, c) => sum + Number(c.amount), 0) * 100) / 100; + const readStoredSessionTax = (closureId: number) => { + const row = db + .prepare(`SELECT tax_components_json FROM cash_closures WHERE id = ?`) + .get(closureId) as { tax_components_json: string }; + return JSON.parse(row.tax_components_json) as Array<{ + title: string; + rate: number | null; + amount: number; + }>; + }; + + const carryBill1 = seedTaxBill( + 'CARRY-1', + 100, + 10, + 110, + [{ label: 'VAT', rate: 10, amount: 10 }], + '2026-11-16 08:05:00', + ); + const carryBill2 = seedTaxBill( + 'CARRY-2', + 90, + 10, + 100, + [ + { label: 'CGST', rate: 5, amount: 5 }, + { label: 'SGST', rate: 5, amount: 5 }, + ], + '2026-11-16 08:05:00', + ); + + const carryShiftA = await withFixedClock('2026-11-16T08:00:00.000Z', async () => { + const res = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }); + assert(res.status === 200, 'Section 13b: Shift A opens on 2026-11-16 08:00'); + return Number(res.body.id); + }); + + await withFixedClock('2026-11-16T09:00:00.000Z', async () => { + const p1 = await request(app) + .post(`/api/bills/${carryBill1}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 44 }); + const p2 = await request(app) + .post(`/api/bills/${carryBill2}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 33.33 }); + assert( + p1.status === 200 && p2.status === 200, + 'Section 13b: Shift A records partial payments (44 of 110, 33.33 of 100) before closing', + ); + const xBeforeClose = await request(app) + .get(`/api/reports/x-report?date=2026-11-16&session_id=${carryShiftA}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xTax = xBeforeClose.body?.xReport?.taxComponents; + assert( + xBeforeClose.status === 200 && + findCompAmt(xTax, 'VAT', 10) === 4 && + sumCompAmts(xTax) === 7.33, + `Section 13b: unsettled partial payments attribute proportional tax (7.33, VAT=4) to Shift A live X report before settlement (got ${JSON.stringify(xTax)})`, + ); + }); + + const closeCarryA = await withFixedClock('2026-11-16T10:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${carryShiftA}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 7733 }), + ); + assert( + closeCarryA.status === 200 && closeCarryA.body?.variance_cents === 0, + 'Section 13b: Shift A closes at 10:00 before bill settlement (200)', + ); + const zCarryABeforeSettlement = readStoredSessionTax(closeCarryA.body.closure_id); + + const carryShiftB = await withFixedClock('2026-11-16T11:00:00.000Z', async () => { + const res = await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 0 }); + assert(res.status === 200, 'Section 13b: Shift B opens on 2026-11-16 11:00'); + return Number(res.body.id); + }); + + await withFixedClock('2026-11-16T12:00:00.000Z', async () => { + const p1 = await request(app) + .post(`/api/bills/${carryBill1}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 66 }); + const p2 = await request(app) + .post(`/api/bills/${carryBill2}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 66.67 }); + assert( + p1.status === 200 && p2.status === 200, + 'Section 13b: Shift B settles remaining balances (66 and 66.67) after Shift A closed', + ); + }); + + const { closeCarryB, closeCarryDay, xCarryARecomputed } = await withFixedClock( + '2026-11-16T13:00:00.000Z', + async () => { + const bClose = await request(app) + .post(`/api/cash-sessions/${carryShiftB}/close`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ counted_cash_cents: 13267 }); + const dClose = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-16', + opening_float_cents: 0, + counted_cash_cents: 21000, + }); + const xRecomp = await request(app) + .get(`/api/reports/x-report?date=2026-11-16&session_id=${carryShiftA}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + return { closeCarryB: bClose, closeCarryDay: dClose, xCarryARecomputed: xRecomp }; + }, + ); + + const zCarryAAfterSettlement = readStoredSessionTax(closeCarryA.body.closure_id); + const zCarryB = readStoredSessionTax(closeCarryB.body.closure_id); + const zCarryDay = closeCarryDay.body?.zReport?.tax_components; + const xCarryATax = xCarryARecomputed.body?.xReport?.taxComponents; + + assert( + closeCarryB.status === 200 && + closeCarryDay.status === 201 && + JSON.stringify(zCarryAAfterSettlement) === JSON.stringify(zCarryABeforeSettlement) && + JSON.stringify(xCarryATax) === JSON.stringify(zCarryAAfterSettlement) && + findCompAmt(zCarryAAfterSettlement, 'VAT', 10) === 4 && + findCompAmt(zCarryB, 'VAT', 10) === 6 && + findCompAmt(zCarryDay, 'VAT', 10) === 10 && + Math.round( + (findCompAmt(zCarryAAfterSettlement, 'CGST', 5) + findCompAmt(zCarryB, 'CGST', 5)) * + 100, + ) / + 100 === + findCompAmt(zCarryDay, 'CGST', 5) && + Math.round( + (findCompAmt(zCarryAAfterSettlement, 'SGST', 5) + findCompAmt(zCarryB, 'SGST', 5)) * + 100, + ) / + 100 === + findCompAmt(zCarryDay, 'SGST', 5) && + sumCompAmts(zCarryAAfterSettlement) === 7.33 && + sumCompAmts(zCarryB) === 12.67 && + sumCompAmts(zCarryDay) === 20 && + Math.round((sumCompAmts(zCarryAAfterSettlement) + sumCompAmts(zCarryB)) * 100) / 100 === + sumCompAmts(zCarryDay), + `Section 13b: Shift A stored Z + Shift B stored Z conserves whole-bill/day tax when Shift A closes before settlement, and Shift A recomputed X matches stored Z (zA=${JSON.stringify(zCarryAAfterSettlement)}, zB=${JSON.stringify(zCarryB)}, day=${JSON.stringify(zCarryDay)})`, + ); + + // Section 13c (i): overlapping shifts with untagged legacy payment (cash_session_id: null) + explicit cash_session_id: 0 + const overlapShiftA = await withFixedClock('2026-11-17T08:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const overlapShiftB = await withFixedClock('2026-11-17T09:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const legacyNullBill = seedTaxBill( + 'LEGACY-NULL-1', + 100, + 10, + 110, + [{ label: 'VAT', rate: 10, amount: 10 }], + '2026-11-17 09:30:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 110, balance = 0, payment_status = 'paid', + paid_at = '2026-11-17 10:00:00', payment_details = ? + WHERE id = ?`, + ).run( + JSON.stringify([ + { + method: 'cash', + amount: 110, + timestamp: '2026-11-17 10:00:00', + cash_session_id: null, + }, + ]), + legacyNullBill, + ); + const explicitZeroBill = seedTaxBill( + 'EXPLICIT-ZERO-1', + 50, + 5, + 55, + [{ label: 'VAT', rate: 10, amount: 5 }], + '2026-11-17 09:45:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 55, balance = 0, payment_status = 'paid', + paid_at = '2026-11-17 10:15:00', payment_details = ? + WHERE id = ?`, + ).run( + JSON.stringify([ + { + method: 'card', + amount: 55, + timestamp: '2026-11-17 10:15:00', + cash_session_id: 0, + }, + ]), + explicitZeroBill, + ); + + const closeOverlapA = await withFixedClock('2026-11-17T11:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${overlapShiftA}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 11000 }), + ); + const closeOverlapB = await withFixedClock('2026-11-17T12:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${overlapShiftB}/close`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ counted_cash_cents: 11000 }), + ); + assert( + closeOverlapA.status === 200 && closeOverlapB.status === 200, + 'Section 13c (i): overlapping shifts close on 2026-11-17 (200)', + ); + const zOverlapA = readStoredSessionTax(closeOverlapA.body.closure_id); + const zOverlapB = readStoredSessionTax(closeOverlapB.body.closure_id); + assert( + sumCompAmts(zOverlapA) === 0 && + findCompAmt(zOverlapB, 'VAT', 10) === 10 && + sumCompAmts(zOverlapA) + sumCompAmts(zOverlapB) === 10, + `Section 13c (i): untagged legacy payment resolves deterministically to one shift without overlap duplication and preserves explicit cash_session_id=0 (zA=${JSON.stringify(zOverlapA)}, zB=${JSON.stringify(zOverlapB)})`, + ); + + // Section 13c (ii): overlapping shifts settling retained legacy partial installments via POST /api/bills/:id/payment + const legacyPartialBill = seedTaxBill( + 'LEGACY-PARTIAL-1', + 100, + 10, + 110, + [{ label: 'VAT', rate: 10, amount: 10 }], + '2026-01-01 10:00:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 44, balance = 66, payment_status = 'partial', + paid_at = NULL, created_at = '2026-01-01 10:00:00', payment_details = ? + WHERE id = ?`, + ).run(JSON.stringify([{ method: 'cash', amount: 44 }]), legacyPartialBill); + + const migShiftA = await withFixedClock('2026-11-18T08:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const migShiftB = await withFixedClock('2026-11-18T09:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const legacyOverlapPartialBill = seedTaxBill( + 'LEGACY-PARTIAL-OVERLAP-1', + 50, + 5, + 55, + [{ label: 'VAT', rate: 10, amount: 5 }], + '2026-11-18 09:30:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 22, balance = 33, payment_status = 'partial', + paid_at = NULL, created_at = '2026-11-18 09:30:00', payment_details = ? + WHERE id = ?`, + ).run(JSON.stringify([{ method: 'card', amount: 22 }]), legacyOverlapPartialBill); + + const migPay = await withFixedClock('2026-11-18T10:00:00.000Z', async () => + request(app) + .post(`/api/bills/${legacyPartialBill}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 66 }), + ); + const migOverlapPay = await withFixedClock('2026-11-18T10:05:00.000Z', async () => + request(app) + .post(`/api/bills/${legacyOverlapPartialBill}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'card', amount: 33, transaction_id: 'TX-LEGACY-OVERLAP-1' }), + ); + const closeMigA = await withFixedClock('2026-11-18T11:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${migShiftA}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 0 }), + ); + const closeMigB = await withFixedClock('2026-11-18T12:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${migShiftB}/close`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ counted_cash_cents: 6600 }), + ); + const closeMigDay = await withFixedClock('2026-11-18T13:00:00.000Z', async () => + request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-18', + opening_float_cents: 0, + counted_cash_cents: 6600, + }), + ); + const zMigA = readStoredSessionTax(closeMigA.body.closure_id); + const zMigB = readStoredSessionTax(closeMigB.body.closure_id); + const zMigDay = closeMigDay.body?.zReport?.tax_components; + assert( + migPay.status === 200 && + migOverlapPay.status === 200 && + closeMigA.status === 200 && + closeMigB.status === 200 && + closeMigDay.status === 201 && + closeMigA.body?.expected_cash_cents === 0 && + closeMigB.body?.expected_cash_cents === 6600 && + closeMigDay.body?.zReport?.expected_cash_cents === 6600 && + sumCompAmts(zMigA) === 0 && + findCompAmt(zMigB, 'VAT', 10) === 11 && + findCompAmt(zMigDay, 'VAT', 10) === 15, + `Section 13c (ii): retained legacy partial installments keep created_at anchor across settlement without moving pre-shift cash/tax or duplicating overlap tax (zA=${JSON.stringify(zMigA)}, zB=${JSON.stringify(zMigB)}, day=${JSON.stringify(zMigDay)})`, + ); + + // Section 13d: single-session paid bill preserves sub-cent historical tax precision + const subCentShift = await withFixedClock('2026-11-19T08:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const subCentBill = seedTaxBill( + 'SUBCENT-1', + 100, + 0.005, + 100, + [{ label: 'VAT', rate: 10, amount: 0.005 }], + '2026-11-19 08:30:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 100, balance = 0, payment_status = 'paid', + paid_at = '2026-11-19 09:00:00', payment_details = ? + WHERE id = ?`, + ).run( + JSON.stringify([ + { + method: 'card', + amount: 100, + timestamp: '2026-11-19 09:00:00', + cash_session_id: subCentShift, + }, + ]), + subCentBill, + ); + const subCentSplitBill = seedTaxBill( + 'SUBCENT-SPLIT-1', + 95, + 5.01, + 100.01, + [ + { label: 'CGST', rate: 2.5, amount: 2.505 }, + { label: 'SGST', rate: 2.5, amount: 2.505 }, + ], + '2026-11-19 08:45:00', + ); + const subCentSplitPay = await withFixedClock('2026-11-19T09:15:00.000Z', async () => + request(app) + .post(`/api/bills/${subCentSplitBill}/payments`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ + payments: [ + { method: 'cash', amount: 40 }, + { method: 'card', amount: 60.01, transaction_id: 'TX-SUBCENT-SPLIT-1' }, + ], + }), + ); + const { xSubCent, xSubCentDay, closeSubCent, closeSubCentDay } = await withFixedClock( + '2026-11-19T10:00:00.000Z', + async () => { + const xRes = await request(app) + .get(`/api/reports/x-report?date=2026-11-19&session_id=${subCentShift}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xDayRes = await request(app) + .get('/api/reports/x-report?date=2026-11-19') + .set('Authorization', `Bearer ${s11ManagerToken}`); + const cRes = await request(app) + .post(`/api/cash-sessions/${subCentShift}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 4000 }); + const dRes = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-19', + opening_float_cents: 0, + counted_cash_cents: 4000, + }); + return { + xSubCent: xRes, + xSubCentDay: xDayRes, + closeSubCent: cRes, + closeSubCentDay: dRes, + }; + }, + ); + const zSubCent = readStoredSessionTax(closeSubCent.body.closure_id); + const zSubCentDay = closeSubCentDay.body?.zReport?.tax_components; + assert( + subCentSplitPay.status === 200 && + xSubCent.status === 200 && + xSubCentDay.status === 200 && + closeSubCent.status === 200 && + closeSubCentDay.status === 201 && + findCompAmt(xSubCent.body?.xReport?.taxComponents, 'VAT', 10) === 0.005 && + findCompAmt(xSubCent.body?.xReport?.taxComponents, 'CGST', 2.5) === 2.505 && + findCompAmt(xSubCent.body?.xReport?.taxComponents, 'SGST', 2.5) === 2.505 && + findCompAmt(zSubCent, 'VAT', 10) === 0.005 && + findCompAmt(zSubCent, 'CGST', 2.5) === 2.505 && + findCompAmt(zSubCent, 'SGST', 2.5) === 2.505 && + JSON.stringify(xSubCent.body?.xReport?.taxComponents) === + JSON.stringify(xSubCentDay.body?.xReport?.taxComponents) && + JSON.stringify(zSubCent) === JSON.stringify(zSubCentDay), + `Section 13d: single-session paid bills (single-line and split-tender) preserve exact sub-cent historical tax precision and match whole-day X/Z (x=${JSON.stringify(xSubCent.body?.xReport?.taxComponents)}, z=${JSON.stringify(zSubCent)}, day=${JSON.stringify(zSubCentDay)})`, + ); + + const skewShift = await withFixedClock('2026-11-20T10:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const skewOwnedBill = seedTaxBill( + 'SKEW-OWNED-1', + 100, + 10, + 110, + [{ label: 'VAT', rate: 10, amount: 10 }], + '2026-11-20 10:02:00', + ); + const skewOwnedPay = await withFixedClock('2026-11-20T10:05:10.000Z', async () => + request(app) + .post(`/api/bills/${skewOwnedBill}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 110 }), + ); + const skewUntaggedFutureBill = seedTaxBill( + 'SKEW-UNTAGGED-1', + 50, + 5, + 55, + [{ label: 'VAT', rate: 10, amount: 5 }], + '2026-11-20 10:10:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 55, balance = 0, payment_status = 'paid', + paid_at = '2026-11-20 10:10:00', payment_details = ? + WHERE id = ?`, + ).run(JSON.stringify([{ method: 'card', amount: 55 }]), skewUntaggedFutureBill); + const { xSkew, closeSkew } = await withFixedClock('2026-11-20T10:05:05.000Z', async () => { + const xRes = await request(app) + .get(`/api/reports/x-report?date=2026-11-20&session_id=${skewShift}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const cRes = await request(app) + .post(`/api/cash-sessions/${skewShift}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 11000 }); + return { xSkew: xRes, closeSkew: cRes }; + }); + const zSkew = readStoredSessionTax(closeSkew.body.closure_id); + assert( + skewOwnedPay.status === 200 && + xSkew.status === 200 && + closeSkew.status === 200 && + closeSkew.body?.variance_cents === 0 && + xSkew.body?.xReport?.grossCollected === 110 && + findCompAmt(xSkew.body?.xReport?.taxComponents, 'VAT', 10) === 10 && + findCompAmt(zSkew, 'VAT', 10) === 10, + `Section 13e: explicit session ownership retains tax across backward clock step while untagged lines without timestamp obey line_time < end (x=${JSON.stringify(xSkew.body?.xReport?.taxComponents)}, z=${JSON.stringify(zSkew)})`, + ); + + const splitKey = 'split_checks_enabled'; + const prevSplit = getSettingValue(splitKey); + db.prepare( + `INSERT INTO settings (key, value, updated_at) + VALUES (?, 'true', '2026-11-21 08:00:00') + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = excluded.updated_at`, + ).run(splitKey); + try { + const vatComp = { label: 'VAT', rate: 10, amount: 3 }; + const splitCatSnap = { lines: [{ taxableBase: 30, components: [vatComp] }] }; + const splitOrderSnap = JSON.stringify([splitCatSnap]); + const splitItemSnap = JSON.stringify(splitCatSnap); + const vatBk = { title: 'VAT', rate: 10, amount: 3 }; + const cityBk = { title: 'City Tax', rate: 5, amount: 0.05 }; + const splitCatBk = JSON.stringify([vatBk]); + const splitLegBk = JSON.stringify([cityBk]); + const splitBillBk = JSON.stringify([vatBk, cityBk]); + const insSplitOrder = db.prepare( + `INSERT INTO orders ( + order_number, type, status, user_id, subtotal, tax_amount, + tax_snapshot, tax_breakdown, total, created_at, updated_at + ) VALUES ( + 'ORD-SPLIT-MIX-1', 'dine_in', 'ready', 'cashier-sess', 33, 3.05, + ?, ?, 36.05, '2026-11-21 08:05:00', '2026-11-21 08:05:00' + )`, + ); + const splitOrderRes = insSplitOrder.run(splitOrderSnap, splitBillBk); + const splitOrderId = Number(splitOrderRes.lastInsertRowid); + const insSplitItem = db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, subtotal, + tax_amount, total, tax_snapshot, tax_breakdown, status, created_at, updated_at + ) VALUES ( + ?, 'prod-v314', ?, 3, ?, ?, ?, ?, ?, ?, 'delivered', + '2026-11-21 08:05:00', '2026-11-21 08:05:00' + )`, + ); + const catItemRes = insSplitItem.run( + splitOrderId, + 'Split Cat Item', + 10, + 30, + 3, + 33, + splitItemSnap, + splitCatBk, + ); + const legItemRes = insSplitItem.run( + splitOrderId, + 'Split Legacy Item', + 1, + 3, + 0.05, + 3.05, + null, + splitLegBk, + ); + const catItemId = Number(catItemRes.lastInsertRowid); + const legItemId = Number(legItemRes.lastInsertRowid); + const insSplitBill = db.prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, tax_breakdown, + total, paid_amount, balance, payment_status, payment_details, created_at, updated_at + ) VALUES ( + 'B-SPLIT-MIX-1', ?, 33, 3.05, ?, ?, 36.05, 0, 36.05, 'unpaid', NULL, + '2026-11-21 08:05:00', '2026-11-21 08:05:00' + )`, + ); + const srcBillRes = insSplitBill.run(splitOrderId, splitOrderSnap, splitBillBk); + const srcBillId = Number(srcBillRes.lastInsertRowid); + const checkItems = [ + { order_item_id: catItemId, quantity: 1 }, + { order_item_id: legItemId, quantity: 1 }, + ]; + const splitChecksPayload = [ + { label: 'Guest 1', items: checkItems }, + { label: 'Guest 2', items: checkItems }, + { label: 'Guest 3', items: checkItems }, + ]; + const splitRes = await withFixedClock('2026-11-21T08:10:00.000Z', async () => + request(app) + .post(`/api/bills/${srcBillId}/split-check`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ checks: splitChecksPayload }), + ); + const splitBills = splitRes.body?.bills || []; + const splitShiftA = await withFixedClock('2026-11-21T09:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const paySplit0 = await withFixedClock('2026-11-21T09:30:00.000Z', async () => + request(app) + .post(`/api/bills/${splitBills[0].id}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 12.02 }), + ); + const paySplit1 = await withFixedClock('2026-11-21T09:35:00.000Z', async () => + request(app) + .post(`/api/bills/${splitBills[1].id}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 12.02 }), + ); + const closeSplitA = await withFixedClock('2026-11-21T10:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${splitShiftA}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 2404 }), + ); + const splitShiftB = await withFixedClock('2026-11-21T11:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const paySplit2 = await withFixedClock('2026-11-21T11:30:00.000Z', async () => + request(app) + .post(`/api/bills/${splitBills[2].id}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 12.01 }), + ); + const s13f = await withFixedClock('2026-11-21T12:00:00.000Z', async () => { + const xSplitB = await request(app) + .get(`/api/reports/x-report?date=2026-11-21&session_id=${splitShiftB}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xSplitDay = await request(app) + .get('/api/reports/x-report?date=2026-11-21') + .set('Authorization', `Bearer ${s11ManagerToken}`); + const closeSplitB = await request(app) + .post(`/api/cash-sessions/${splitShiftB}/close`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ counted_cash_cents: 1201 }); + const closeSplitDay = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-21', + opening_float_cents: 0, + counted_cash_cents: 3605, + }); + const taxCompRep = await request(app) + .get('/api/reports/tax-components?start_date=2026-11-21&end_date=2026-11-21') + .set('Authorization', `Bearer ${s11ManagerToken}`); + return { xSplitB, xSplitDay, closeSplitB, closeSplitDay, taxCompRep }; + }); + const zSplitA = readStoredSessionTax(closeSplitA.body.closure_id); + const zSplitB = readStoredSessionTax(s13f.closeSplitB.body.closure_id); + const zSplitDay = s13f.closeSplitDay.body?.zReport?.tax_components; + const xSplitBTax = s13f.xSplitB.body?.xReport?.taxComponents; + const xSplitDayTax = s13f.xSplitDay.body?.xReport?.taxComponents; + const repTaxComps = s13f.taxCompRep.body?.taxComponents?.components; + assert( + splitRes.status === 201 && + splitBills.length === 3 && + paySplit0.status === 200 && + paySplit1.status === 200 && + paySplit2.status === 200 && + closeSplitA.status === 200 && + s13f.closeSplitB.status === 200 && + s13f.closeSplitDay.status === 201 && + s13f.xSplitB.status === 200 && + s13f.xSplitDay.status === 200 && + s13f.taxCompRep.status === 200 && + findCompAmt(zSplitA, 'VAT', 10) === 2 && + findCompAmt(zSplitA, 'City Tax', 5) === 0.04 && + findCompAmt(xSplitBTax, 'VAT', 10) === 1 && + findCompAmt(xSplitBTax, 'City Tax', 5) === 0.01 && + JSON.stringify(zSplitB) === JSON.stringify(xSplitBTax) && + findCompAmt(xSplitDayTax, 'VAT', 10) === 3 && + findCompAmt(xSplitDayTax, 'City Tax', 5) === 0.05 && + JSON.stringify(zSplitDay) === JSON.stringify(xSplitDayTax) && + JSON.stringify(zSplitDay) === JSON.stringify(repTaxComps), + `Section 13f: split-check mixed snapshot and legacy breakdown preserves child owner allocations across shift X/Z, day X/Z, and tax-components report (zA=${JSON.stringify(zSplitA)}, zB=${JSON.stringify(zSplitB)}, day=${JSON.stringify(zSplitDay)})`, + ); + } finally { + if (prevSplit === null) { + db.prepare('DELETE FROM settings WHERE key = ?').run(splitKey); + } else { + db.prepare('UPDATE settings SET value = ? WHERE key = ?').run(prevSplit, splitKey); + } + } + + // Section 13g: closed Shift A before settlement of a timestamp-less legacy partial installment + const lsShiftA = await withFixedClock('2026-11-22T08:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const lsBill = seedTaxBill( + 'LEGACY-EARLY-CLOSE-1', + 100, + 10, + 110, + [{ label: 'VAT', rate: 10, amount: 10 }], + '2026-11-22 08:05:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 44, balance = 66, payment_status = 'partial', + paid_at = NULL, created_at = '2026-11-22 08:05:00', payment_details = ? + WHERE id = ?`, + ).run(JSON.stringify([{ method: 'cash', amount: 44 }]), lsBill); + + const closeLsA = await withFixedClock('2026-11-22T10:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${lsShiftA}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 4400 }), + ); + const lsClosureIdA = Number(closeLsA.body?.closure_id); + const lsClosureRowBefore = JSON.stringify( + db.prepare('SELECT * FROM cash_closures WHERE id = ?').get(lsClosureIdA), + ); + const zLsABefore = readStoredSessionTax(lsClosureIdA); + + const lsShiftB = await withFixedClock('2026-11-22T11:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const lsPayB = await withFixedClock('2026-11-22T12:00:00.000Z', async () => + request(app) + .post(`/api/bills/${lsBill}/payment`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ method: 'cash', amount: 66 }), + ); + const closeLsB = await withFixedClock('2026-11-22T13:00:00.000Z', async () => + request(app) + .post(`/api/cash-sessions/${lsShiftB}/close`) + .set('Authorization', `Bearer ${s11CashierBToken}`) + .send({ counted_cash_cents: 6600 }), + ); + const { closeLsDay, xLsAAfter } = await withFixedClock( + '2026-11-22T14:00:00.000Z', + async () => { + const dRes = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-22', + opening_float_cents: 0, + counted_cash_cents: 11000, + }); + const xRes = await request(app) + .get(`/api/reports/x-report?date=2026-11-22&session_id=${lsShiftA}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + return { closeLsDay: dRes, xLsAAfter: xRes }; + }, + ); + const lsClosureRowAfter = JSON.stringify( + db.prepare('SELECT * FROM cash_closures WHERE id = ?').get(lsClosureIdA), + ); + const zLsAAfter = readStoredSessionTax(lsClosureIdA); + const zLsB = readStoredSessionTax(Number(closeLsB.body?.closure_id)); + const zLsDay = closeLsDay.body?.zReport?.tax_components; + const xLsATax = xLsAAfter.body?.xReport?.taxComponents; + const zLsAMinor = Math.round(findCompAmt(zLsAAfter, 'VAT', 10) * 100); + const zLsBMinor = Math.round(findCompAmt(zLsB, 'VAT', 10) * 100); + const zLsDayMinor = Math.round(findCompAmt(zLsDay, 'VAT', 10) * 100); + assert( + closeLsA.status === 200 && + lsPayB.status === 200 && + closeLsB.status === 200 && + closeLsDay.status === 201 && + xLsAAfter.status === 200 && + closeLsA.body?.expected_cash_cents === 4400 && + closeLsA.body?.variance_cents === 0 && + closeLsB.body?.expected_cash_cents === 6600 && + closeLsB.body?.variance_cents === 0 && + closeLsDay.body?.zReport?.expected_cash_cents === 11000 && + xLsAAfter.body?.xReport?.expectedCashCents === 4400 && + xLsAAfter.body?.xReport?.grossCollected === 44 && + lsClosureRowBefore === lsClosureRowAfter && + JSON.stringify(zLsABefore) === JSON.stringify(zLsAAfter) && + JSON.stringify(zLsAAfter) === JSON.stringify(xLsATax) && + zLsAMinor === 400 && + zLsBMinor === 600 && + zLsDayMinor === 1000 && + zLsAMinor + zLsBMinor === zLsDayMinor, + `Section 13g: closed Shift A before settlement of timestamp-less legacy installment keeps immutable Z, matches recomputed X, and conserves minor-unit tax with Shift B and day Z (zABefore=${JSON.stringify(zLsABefore)}, zAAfter=${JSON.stringify(zLsAAfter)}, xA=${JSON.stringify(xLsATax)}, zB=${JSON.stringify(zLsB)}, day=${JSON.stringify(zLsDay)})`, + ); + + // Section 13h: single-payment legacy settled bill without timestamp still uses paid_at + const singleLegacyShift = await withFixedClock('2026-11-23T08:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const singleLegacyBill = seedTaxBill( + 'LEGACY-SINGLE-PAID-AT-1', + 50, + 5, + 55, + [{ label: 'VAT', rate: 10, amount: 5 }], + '2026-11-23 07:30:00', + ); + db.prepare( + `UPDATE bills + SET paid_amount = 55, balance = 0, payment_status = 'paid', + created_at = '2026-11-23 07:30:00', paid_at = '2026-11-23 08:30:00', + payment_details = ? + WHERE id = ?`, + ).run(JSON.stringify([{ method: 'cash', amount: 55 }]), singleLegacyBill); + const { xSingleLegacy, closeSingleLegacy, closeSingleLegacyDay } = await withFixedClock( + '2026-11-23T09:00:00.000Z', + async () => { + const xRes = await request(app) + .get(`/api/reports/x-report?date=2026-11-23&session_id=${singleLegacyShift}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const cRes = await request(app) + .post(`/api/cash-sessions/${singleLegacyShift}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 5500 }); + const dRes = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-23', + opening_float_cents: 0, + counted_cash_cents: 5500, + }); + return { + xSingleLegacy: xRes, + closeSingleLegacy: cRes, + closeSingleLegacyDay: dRes, + }; + }, + ); + const zSingleLegacy = readStoredSessionTax(Number(closeSingleLegacy.body?.closure_id)); + const zSingleLegacyDay = closeSingleLegacyDay.body?.zReport?.tax_components; + assert( + xSingleLegacy.status === 200 && + closeSingleLegacy.status === 200 && + closeSingleLegacyDay.status === 201 && + closeSingleLegacy.body?.expected_cash_cents === 5500 && + closeSingleLegacy.body?.variance_cents === 0 && + xSingleLegacy.body?.xReport?.expectedCashCents === 5500 && + findCompAmt(xSingleLegacy.body?.xReport?.taxComponents, 'VAT', 10) === 5 && + findCompAmt(zSingleLegacy, 'VAT', 10) === 5 && + JSON.stringify(zSingleLegacy) === JSON.stringify(zSingleLegacyDay), + `Section 13h: single-payment legacy settled bill without timestamp still attributes cash and tax at paid_at (x=${JSON.stringify(xSingleLegacy.body?.xReport?.taxComponents)}, z=${JSON.stringify(zSingleLegacy)}, day=${JSON.stringify(zSingleLegacyDay)})`, + ); + + const cancelShift = await withFixedClock('2026-11-24T08:00:00.000Z', async () => + Number( + ( + await request(app) + .post('/api/cash-sessions/open') + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ opening_float_cents: 0 }) + ).body.id, + ), + ); + const cancelPartialBill = seedTaxBill( + 'CANCEL-PARTIAL-1', + 100, + 10, + 110, + [{ label: 'VAT', rate: 10, amount: 10 }], + '2026-11-24 08:05:00', + ); + const cancelOrderRow = db + .prepare('SELECT order_id FROM bills WHERE id = ?') + .get(cancelPartialBill) as { order_id: number }; + const cancelPartialOrderId = Number(cancelOrderRow.order_id); + const cancelPartialPay = await withFixedClock('2026-11-24T08:15:00.000Z', async () => + request(app) + .post(`/api/bills/${cancelPartialBill}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 44 }), + ); + const xPreCancel = await withFixedClock('2026-11-24T08:18:00.000Z', async () => + request(app) + .get(`/api/reports/x-report?date=2026-11-24&session_id=${cancelShift}`) + .set('Authorization', `Bearer ${s11ManagerToken}`), + ); + const cancelOrderRes = await withFixedClock('2026-11-24T08:20:00.000Z', async () => + request(app) + .patch(`/api/orders/${cancelPartialOrderId}/status`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ + status: 'cancelled', + reason: 'Guest walked out after partial deposit', + approver_id: 'manager-sess', + override_pin: '4321', + }), + ); + const s13iPart1 = await withFixedClock('2026-11-24T08:25:00.000Z', async () => { + const xRes = await request(app) + .get(`/api/reports/x-report?date=2026-11-24&session_id=${cancelShift}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const hRes = await request(app) + .get( + `/api/reports/x-report/export?date=2026-11-24&format=html&session_id=${cancelShift}`, + ) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const tRes = await request(app) + .get('/api/reports/tax-components?start_date=2026-11-24&end_date=2026-11-24') + .set('Authorization', `Bearer ${s11ManagerToken}`); + return { xRes, hRes, tRes }; + }); + assert( + cancelPartialPay.status === 200 && + xPreCancel.status === 200 && + findCompAmt(xPreCancel.body?.xReport?.taxComponents, 'VAT', 10) === 4 && + cancelOrderRes.status === 200 && + s13iPart1.xRes.status === 200 && + s13iPart1.hRes.status === 200 && + s13iPart1.tRes.status === 200 && + (s13iPart1.xRes.body?.xReport?.taxComponents || []).length === 0 && + !s13iPart1.hRes.text.includes('data-section="tax"') && + (s13iPart1.tRes.body?.taxComponents?.components || []).length === 0, + `Section 13i (part 1): cancelling a partially paid unsettled order zeroes shift X, shift export, and tax-components tax (pre=${JSON.stringify(xPreCancel.body?.xReport?.taxComponents)}, post=${JSON.stringify(s13iPart1.xRes.body?.xReport?.taxComponents)})`, + ); + + const scKey = 'split_checks_enabled'; + const prevSplitCancel = getSettingValue(scKey); + db.prepare( + `INSERT INTO settings (key, value, updated_at) + VALUES (?, 'true', '2026-11-24 08:26:00') + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = excluded.updated_at`, + ).run(scKey); + try { + const splitCancelSnap = JSON.stringify({ + lines: [{ taxableBase: 100, components: [{ label: 'VAT', rate: 10, amount: 10 }] }], + }); + const splitCancelOrderSnap = JSON.stringify([JSON.parse(splitCancelSnap)]); + const splitCancelOrderIns = db + .prepare( + `INSERT INTO orders ( + order_number, type, status, user_id, subtotal, tax_amount, + tax_snapshot, total, created_at, updated_at + ) VALUES ( + 'ORD-SPLIT-CANCEL-1', 'dine_in', 'ready', 'cashier-sess', 100, 10, + ?, 110, '2026-11-24 08:27:00', '2026-11-24 08:27:00' + )`, + ) + .run(splitCancelOrderSnap); + const splitCancelOrderId = Number(splitCancelOrderIns.lastInsertRowid); + const splitCancelItemIns = db + .prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, subtotal, + tax_amount, total, tax_snapshot, status, created_at, updated_at + ) VALUES ( + ?, 'prod-v314', 'Split Cancel Item', 2, 50, 100, + 10, 110, ?, 'ready', '2026-11-24 08:27:00', '2026-11-24 08:27:00' + )`, + ) + .run(splitCancelOrderId, splitCancelSnap); + const splitCancelItemId = Number(splitCancelItemIns.lastInsertRowid); + const splitCancelSrcBillIns = db + .prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, + total, paid_amount, balance, payment_status, payment_details, created_at, updated_at + ) VALUES ( + 'B-SPLIT-CANCEL-1', ?, 100, 10, ?, 110, 0, 110, 'unpaid', NULL, + '2026-11-24 08:27:00', '2026-11-24 08:27:00' + )`, + ) + .run(splitCancelOrderId, splitCancelOrderSnap); + const splitCancelSrcBillId = Number(splitCancelSrcBillIns.lastInsertRowid); + const splitCancelRes = await withFixedClock('2026-11-24T08:30:00.000Z', async () => + request(app) + .post(`/api/bills/${splitCancelSrcBillId}/split-check`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ + checks: [ + { + label: 'Guest 1', + items: [{ order_item_id: splitCancelItemId, quantity: 1 }], + }, + { + label: 'Guest 2', + items: [{ order_item_id: splitCancelItemId, quantity: 1 }], + }, + ], + }), + ); + const splitCancelBills = splitCancelRes.body?.bills || []; + const paySettledChild = await withFixedClock('2026-11-24T08:35:00.000Z', async () => + request(app) + .post(`/api/bills/${splitCancelBills[0].id}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 55 }), + ); + const payPartialChild = await withFixedClock('2026-11-24T08:40:00.000Z', async () => + request(app) + .post(`/api/bills/${splitCancelBills[1].id}/payment`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ method: 'cash', amount: 22 }), + ); + const cancelSplitOrderRes = await withFixedClock('2026-11-24T08:45:00.000Z', async () => + request(app) + .patch(`/api/orders/${splitCancelOrderId}/status`) + .set('Authorization', `Bearer ${s11ManagerToken}`) + .send({ + status: 'cancelled', + reason: 'Guest 2 cancelled', + approver_id: 'manager-sess', + override_pin: '4321', + }), + ); + const s13i = await withFixedClock('2026-11-24T09:00:00.000Z', async () => { + const xShiftRes = await request(app) + .get(`/api/reports/x-report?date=2026-11-24&session_id=${cancelShift}`) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const htmlShiftRes = await request(app) + .get( + `/api/reports/x-report/export?date=2026-11-24&format=html&session_id=${cancelShift}`, + ) + .set('Authorization', `Bearer ${s11ManagerToken}`); + const xDayRes = await request(app) + .get('/api/reports/x-report?date=2026-11-24') + .set('Authorization', `Bearer ${s11ManagerToken}`); + const closeShiftRes = await request(app) + .post(`/api/cash-sessions/${cancelShift}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 12100 }); + const closeDayRes = await request(app) + .post('/api/cash-closures') + .set('Authorization', `Bearer ${s11OwnerToken}`) + .send({ + business_date: '2026-11-24', + opening_float_cents: 0, + counted_cash_cents: 12100, + }); + return { xShiftRes, htmlShiftRes, xDayRes, closeShiftRes, closeDayRes }; + }); + const zCancelShift = readStoredSessionTax(Number(s13i.closeShiftRes.body?.closure_id)); + const zCancelDay = s13i.closeDayRes.body?.zReport?.tax_components; + const xCancelShiftTax = s13i.xShiftRes.body?.xReport?.taxComponents; + const xCancelDayTax = s13i.xDayRes.body?.xReport?.taxComponents; + assert( + splitCancelRes.status === 201 && + paySettledChild.status === 200 && + payPartialChild.status === 200 && + cancelSplitOrderRes.status === 200 && + s13i.xShiftRes.status === 200 && + s13i.htmlShiftRes.status === 200 && + s13i.xDayRes.status === 200 && + s13i.closeShiftRes.status === 200 && + s13i.closeShiftRes.body?.variance_cents === 0 && + s13i.closeDayRes.status === 201 && + s13i.closeDayRes.body?.zReport?.variance_cents === 0 && + findCompAmt(xCancelShiftTax, 'VAT', 10) === 5 && + sumCompAmts(xCancelShiftTax) === 5 && + s13i.htmlShiftRes.text.includes( + 'data-section="tax" data-title="VAT" data-rate="10" data-amount="5"', + ) && + JSON.stringify(zCancelShift) === JSON.stringify(xCancelShiftTax) && + JSON.stringify(xCancelDayTax) === JSON.stringify(xCancelShiftTax) && + JSON.stringify(zCancelDay) === JSON.stringify(zCancelShift), + `Section 13i (part 2): settled child check retains tax while unsettled partial checks on cancelled orders contribute zero across shift X/export/Z and day X/Z (xShift=${JSON.stringify(xCancelShiftTax)}, zShift=${JSON.stringify(zCancelShift)}, day=${JSON.stringify(zCancelDay)})`, + ); + } finally { + if (prevSplitCancel === null) { + db.prepare('DELETE FROM settings WHERE key = ?').run(scKey); + } else { + db.prepare('UPDATE settings SET value = ? WHERE key = ?').run(prevSplitCancel, scKey); + } + } + } } 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(); diff --git a/tests/reports-export.test.ts b/tests/reports-export.test.ts index 03d6d2f22..4c833a827 100644 --- a/tests/reports-export.test.ts +++ b/tests/reports-export.test.ts @@ -1488,6 +1488,497 @@ async function run(): Promise { ); } + // ── Session-scoped X-report export (CSV, XLSX, HTML/PDF) + useCashClose hook regression ── + { + const shiftExportDate = '2026-09-28'; + const shift1OpenAt = '2026-09-28 08:00:00'; + const shift2OpenAt = '2026-09-28 09:00:00'; + const shiftSaleAt = '2026-09-28 10:30:00'; + + const s1Insert = db + .prepare( + `INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status) + VALUES (?, ?, ?, ?, 'open')`, + ) + .run('cashier-reports-export', 'Cashier User', shift1OpenAt, 10000); + const shift1Id = Number(s1Insert.lastInsertRowid); + + const s2Insert = db + .prepare( + `INSERT INTO cash_sessions (opened_by, opened_by_name, opened_at, opening_float_cents, status) + VALUES (?, ?, ?, ?, 'open')`, + ) + .run(owner.userId, '=Formula Staff', shift2OpenAt, 20000); + const shift2Id = Number(s2Insert.lastInsertRowid); + + db.prepare( + `INSERT INTO cash_drawer_movements (business_date, movement_type, amount_cents, reason, created_by, created_at) + VALUES (?, 'opening_float', 30000, 'Day float', ?, ?)`, + ).run(shiftExportDate, owner.userId, shift1OpenAt); + db.prepare( + `INSERT INTO cash_drawer_movements (business_date, movement_type, amount_cents, reason, created_by, cash_session_id, created_at) + VALUES (?, 'pay_in', 2500, 'Shift 1 change', 'cashier-reports-export', ?, ?)`, + ).run(shiftExportDate, shift1Id, shiftSaleAt); + + // Bill S1: 150 subtotal + 15 VAT (10%) = 165 paid in Shift 1 (cash) + const ordS1 = db + .prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, tax_amount, total, created_at, updated_at, completed_at) + VALUES ('ORD-SHIFT-EXP-1', 'cashier-reports-export', 'takeaway', 'completed', 150, 15, 165, ?, ?, ?)`, + ) + .run(shiftSaleAt, shiftSaleAt, shiftSaleAt); + const ordS1Id = Number(ordS1.lastInsertRowid); + const s1TaxSnap = JSON.stringify({ + lines: [ + { taxableBase: 150, components: [{ label: 'VAT ', rate: 10, amount: 15 }] }, + ], + }); + db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, product_sku, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at + ) VALUES (?, 'prod-rep-1', 'Shift 1 Item', 'SKU-S1', 1, 150, 150, 15, 165, ?, 40, 1, 'ready', ?, ?)`, + ).run(ordS1Id, s1TaxSnap, shiftSaleAt, shiftSaleAt); + db.prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, total, paid_amount, balance, + payment_status, payment_details, paid_at, created_at, updated_at + ) VALUES ('B-SHIFT-EXP-1', ?, 150, 15, ?, 165, 165, 0, 'paid', ?, ?, ?, ?)`, + ).run( + ordS1Id, + s1TaxSnap, + JSON.stringify([ + { method: 'cash', amount: 165, timestamp: shiftSaleAt, cash_session_id: shift1Id }, + ]), + shiftSaleAt, + shiftSaleAt, + shiftSaleAt, + ); + + // Bill S2: 200 subtotal + 20 VAT (10%) = 220 split 55 (Shift 1 cash) + 165 (Shift 2 card) + const ordS2 = db + .prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, tax_amount, total, created_at, updated_at, completed_at) + VALUES ('ORD-SHIFT-EXP-2', ?, 'takeaway', 'completed', 200, 20, 220, ?, ?, ?)`, + ) + .run(owner.userId, shiftSaleAt, shiftSaleAt, shiftSaleAt); + const ordS2Id = Number(ordS2.lastInsertRowid); + const s2TaxSnap = JSON.stringify({ + lines: [ + { taxableBase: 200, components: [{ label: 'VAT ', rate: 10, amount: 20 }] }, + ], + }); + db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, product_sku, quantity, unit_price, subtotal, tax_amount, total, + tax_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at + ) VALUES (?, 'prod-rep-2', 'Shift Split Item', 'SKU-S2', 1, 200, 200, 20, 220, ?, 50, 1, 'ready', ?, ?)`, + ).run(ordS2Id, s2TaxSnap, shiftSaleAt, shiftSaleAt); + db.prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, tax_amount, tax_snapshot, total, paid_amount, balance, + payment_status, payment_details, paid_at, created_at, updated_at + ) VALUES ('B-SHIFT-EXP-2', ?, 200, 20, ?, 220, 220, 0, 'paid', ?, ?, ?, ?)`, + ).run( + ordS2Id, + s2TaxSnap, + JSON.stringify([ + { method: 'cash', amount: 55, timestamp: shiftSaleAt, cash_session_id: shift1Id }, + { method: 'card', amount: 165, timestamp: shiftSaleAt, cash_session_id: shift2Id }, + ]), + shiftSaleAt, + shiftSaleAt, + shiftSaleAt, + ); + + // Permission & validation checks on GET /api/reports/x-report/export?session_id=... + const badShiftIdRes = await http + .get(`/api/reports/x-report/export?date=${shiftExportDate}&format=csv&session_id=bad`) + .set(auth); + assert.equal(badShiftIdRes.status, 400); + assert.equal(badShiftIdRes.body.code, 'session_id_invalid'); + + const missingShiftIdRes = await http + .get(`/api/reports/x-report/export?date=${shiftExportDate}&format=csv&session_id=999999`) + .set(auth); + assert.equal(missingShiftIdRes.status, 404); + assert.equal(missingShiftIdRes.body.code, 'session_unavailable'); + + db.prepare( + `INSERT INTO user_permission_overrides (user_id, permission_id, effect, updated_by, updated_at) + VALUES ('cashier-reports-export', 'reports.view', 'allow', ?, ?) + ON CONFLICT(user_id, permission_id) DO UPDATE SET effect = 'allow', updated_at = excluded.updated_at`, + ).run(owner.userId, shiftSaleAt); + const cashierAuth = { Authorization: `Bearer ${cashier.token}` }; + + const cashierCrossShift = await http + .get( + `/api/reports/x-report/export?date=${shiftExportDate}&format=csv&session_id=${shift2Id}`, + ) + .set(cashierAuth); + assert.equal( + cashierCrossShift.status, + 403, + 'cashier with reports.view cannot export another staff shift', + ); + assert.equal(cashierCrossShift.body.code, 'shift_access_forbidden'); + + const shift1Csv = await http + .get( + `/api/reports/x-report/export?date=${shiftExportDate}&format=csv&session_id=${shift1Id}`, + ) + .set(cashierAuth); + assert.equal(shift1Csv.status, 200, 'cashier exports own shift X report CSV'); + assert.ok( + shift1Csv.headers['content-disposition'].includes( + `filename="x-report-${shiftExportDate}-shift-${shift1Id}.csv"`, + ), + 'shift CSV filename includes shift identity', + ); + assert.ok( + shift1Csv.text.includes(`summary,Shift,,,,Shift #${shift1Id} - Cashier User\n`), + 'shift CSV includes Shift summary row', + ); + assert.ok(shift1Csv.text.includes('summary,Gross Sales,,,,220\n')); + assert.ok(shift1Csv.text.includes('summary,Expected Cash in Drawer,,,,345\n')); + assert.ok(shift1Csv.text.includes('summary,Opening Float,,,,100\n')); + assert.ok(shift1Csv.text.includes('summary,Pay-in Total,,,,25\n')); + assert.ok(!shift1Csv.text.includes('summary,Gross Sales,,,,385\n')); + + db.prepare( + `DELETE FROM user_permission_overrides WHERE user_id = 'cashier-reports-export' AND permission_id = 'reports.view'`, + ).run(); + + const shift1Xlsx = await http + .get( + `/api/reports/x-report/export?date=${shiftExportDate}&format=xlsx&session_id=${shift1Id}`, + ) + .set(auth) + .buffer(true) + .parse(parseBuffer); + assert.equal(shift1Xlsx.status, 200, 'owner exports Shift 1 XLSX'); + assert.ok( + shift1Xlsx.headers['content-disposition'].includes( + `filename="x-report-${shiftExportDate}-shift-${shift1Id}.xlsx"`, + ), + 'shift XLSX filename includes shift identity', + ); + const shift1Wb = new ExcelJS.Workbook(); + await shift1Wb.xlsx.load(shift1Xlsx.body); + const shift1Metrics = new Map(); + shift1Wb.getWorksheet('Summary').eachRow((row: any, rowNumber: number) => { + if (rowNumber > 1) shift1Metrics.set(String(row.getCell(1).value), row.getCell(2).value); + }); + assert.equal(shift1Metrics.get('Shift'), `Shift #${shift1Id} - Cashier User`); + assert.equal(shift1Metrics.get('Gross Sales'), 220); + assert.equal(shift1Metrics.get('Expected Cash in Drawer'), 345); + assert.equal(shift1Metrics.get('Opening Float'), 100); + assert.equal(shift1Metrics.get('Pay-in Total'), 25); + assert.equal(shift1Metrics.get('Bill Count'), 2); + + const shift1Html = await http + .get( + `/api/reports/x-report/export?date=${shiftExportDate}&format=html&session_id=${shift1Id}`, + ) + .set(auth); + assert.equal(shift1Html.status, 200, 'owner exports Shift 1 HTML'); + assert.ok( + shift1Html.headers['content-disposition'].includes( + `filename="x-report-${shiftExportDate}-shift-${shift1Id}.html"`, + ), + 'shift HTML filename includes shift identity', + ); + const shiftLabel = `Shift #${shift1Id} - Cashier User`; + assert.ok( + shift1Html.text.includes(`X Report (${shiftLabel}) - ${shiftExportDate}`), + 'shift HTML title displays shift identity', + ); + assert.ok( + shift1Html.text.includes(`Shift: ${shiftLabel}`), + 'shift HTML meta header displays shift identity', + ); + assert.ok( + shift1Html.text.includes( + 'data-section="tax" data-title="VAT <Standard>" data-rate="10" data-amount="20"', + ), + 'shift HTML tax breakdown displays proportional Shift 1 tax (15 + 5 = 20, not 35)', + ); + const shift1HtmlMetrics = parseHtmlSummaryMetrics(shift1Html.text); + for (const [metric, xlsxVal] of shift1Metrics.entries()) { + assert.equal( + shift1HtmlMetrics.get(metric), + xlsxVal === null || xlsxVal === undefined ? '' : String(xlsxVal), + `shift HTML metric ${metric} matches shift XLSX`, + ); + } + + // Unscoped whole-day export on the same date preserves whole-day figures and filename + const unscopedDay4Csv = await http + .get(`/api/reports/x-report/export?date=${shiftExportDate}&format=csv`) + .set(auth); + assert.equal(unscopedDay4Csv.status, 200); + assert.ok( + unscopedDay4Csv.headers['content-disposition'].includes( + `filename="x-report-${shiftExportDate}.csv"`, + ), + ); + assert.ok(!unscopedDay4Csv.text.includes('summary,Shift,')); + assert.ok(unscopedDay4Csv.text.includes('summary,Gross Sales,,,,385\n')); + + // Render Shift 1 HTML to PDF via save-html-as-pdf IPC + const shift1PdfPath = path.join(testDir, `x-report-${shiftExportDate}-shift-${shift1Id}.pdf`); + saveDialogResult = { canceled: false, filePath: shift1PdfPath }; + const shift1PdfSave = await saveHtmlAsPdfIpc!(trustedSender, { + html: shift1Html.text, + defaultFileName: `x-report-${shiftExportDate}-shift-${shift1Id}.pdf`, + pageSize: 'A4', + }); + assert.deepEqual(shift1PdfSave, { success: true, path: shift1PdfPath }); + const shift1PdfBuffer = fs.readFileSync(shift1PdfPath); + if (isRealElectronRuntime) { + const shift1PdfInfo = extractPdfInfo(shift1PdfBuffer); + assert.ok( + pdfTextIncludesToken(shift1PdfInfo, `X Report (${shiftLabel}) - ${shiftExportDate}`), + 'real Shift 1 PDF renders shift-scoped title', + ); + assert.ok( + pdfTextIncludesToken(shift1PdfInfo, '₹220.00'), + 'real Shift 1 PDF renders Shift 1 gross sales', + ); + const shift1Layout = await verifyHtmlLayout(shift1Html.text, 'portrait', 'x-report-shift'); + assert.ok( + shift1Layout.scrollWidth <= shift1Layout.clientWidth && + shift1Layout.overflowingCells === 0, + `Shift 1 X report PDF layout has no horizontal clipping or overflow (${JSON.stringify(shift1Layout)})`, + ); + } else { + // Direct frontend useCashClose hook verification (selectedSessionId null vs selected) + const hookStates: unknown[] = []; + let hookStateCursor = 0; + const capturedApiGets: Array<{ url: string; params: Record }> = []; + const capturedDownloads: Array<{ filename: string; text: string }> = []; + const pendingDownloadReads: Array> = []; + const capturedPdfExports: Array<{ + endpoint: string; + params?: Record; + filename: string; + }> = []; + + class StubPopupBlockedError extends Error {} + const priorLoad = Module._load; + Module._load = function (reqName: string, parentMod: unknown, isMainMod: boolean) { + if (reqName === 'react') { + return { + useState: (initial: T) => { + const idx = hookStateCursor++; + if (!(idx in hookStates)) { + hookStates[idx] = + typeof initial === 'function' ? (initial as () => T)() : initial; + } + const setter = (next: T | ((prev: T) => T)) => { + hookStates[idx] = + typeof next === 'function' + ? (next as (prev: T) => T)(hookStates[idx] as T) + : next; + }; + return [hookStates[idx] as T, setter] as const; + }, + useEffect: () => undefined, + useCallback: any>(fn: T) => fn, + }; + } + if (reqName === '@/store/auth') { + return { + useAuthStore: () => ({ + currentTenant: { + currency: 'INR', + timezone: 'UTC', + business_day_start_time: '00:00', + country: 'IN', + permission_ids: [], + }, + }), + }; + } + if (reqName === '@/lib/api') { + return { + __esModule: true, + default: { + get: async (url: string, config?: { params?: Record }) => { + const params = config?.params ?? {}; + capturedApiGets.push({ url, params }); + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined && v !== null) qs.set(k, String(v)); + } + const apiRes = await http + .get(`/api${url}?${qs.toString()}`) + .set(auth) + .buffer(true) + .parse(parseBuffer); + assert.equal(apiRes.status, 200, `useCashClose api.get ${url} succeeds`); + return { data: new Blob([apiRes.body]) }; + }, + post: async () => ({ data: {} }), + }, + }; + } + if (reqName === 'use-intl') { + return { + useLocale: () => 'en', + useTranslations: () => (key: string) => key, + }; + } + if (reqName === 'react-hot-toast') { + return { + __esModule: true, + default: { + success: () => undefined, + error: (msg: string) => { + throw new Error(`Unexpected toast.error in useCashClose: ${msg}`); + }, + }, + }; + } + if (reqName === '@/hooks/useFormatCurrency') { + return { useFormatCurrency: () => (n: number) => String(n) }; + } + if (reqName === '@/hooks/useCurrencyUnitAdapter') { + return { + useCurrencyUnitAdapter: () => ({ + toDisplay: (n: number) => n, + fromDisplay: (n: number) => n, + maxDecimals: 2, + }), + }; + } + if (reqName === '@/lib/countries') { + return { getCurrencyMinorUnitFactor: () => 100 }; + } + if (reqName === '@/lib/printer/PrinterService') { + return { printerService: { printZReport: async () => undefined } }; + } + if (reqName === '@/lib/money') { + return { displayAmountToCents: (v: string) => Math.round(Number(v || 0) * 100) }; + } + if (reqName === '@shared/business-date') { + return { businessDateForInstant: () => shiftExportDate }; + } + if (reqName === '@/lib/download') { + return { + downloadBlob: (blob: Blob, filename: string) => { + const entry = { filename, text: '' }; + capturedDownloads.push(entry); + pendingDownloadReads.push( + blob.text().then((t) => { + entry.text = t; + }), + ); + }, + }; + } + if (reqName === '@/lib/export-pdf') { + return { + MenuPopupBlockedError: StubPopupBlockedError, + exportReportAsPdf: async (opts: { + endpoint: string; + params?: Record; + filename: string; + }) => { + capturedPdfExports.push(opts); + }, + }; + } + if (reqName === '@/lib/whatsapp-share') { + return { shareCashCloseViaWhatsApp: async () => ({ mode: 'sent' }) }; + } + return priorLoad.call(this, reqName, parentMod, isMainMod); + }; + + try { + const hookModulePath = require.resolve('../frontend/src/hooks/useCashClose'); + delete require.cache[hookModulePath]; + const { useCashClose } = require('../frontend/src/hooks/useCashClose') as { + useCashClose: () => { + businessDate: string; + selectedSessionId: number | null; + setSelectedSessionId: (id: number | null) => void; + exportReport: (report: 'x' | 'z', format: 'csv' | 'xlsx' | 'pdf') => Promise; + }; + }; + const renderUseCashClose = () => { + hookStateCursor = 0; + return useCashClose(); + }; + + // 1) All shifts (selectedSessionId === null) -> whole-day params & filename + let model = renderUseCashClose(); + assert.equal(model.selectedSessionId, null); + await model.exportReport('x', 'csv'); + await model.exportReport('x', 'xlsx'); + await model.exportReport('x', 'pdf'); + await Promise.all(pendingDownloadReads.splice(0)); + + assert.deepEqual(capturedApiGets[0], { + url: '/reports/x-report/export', + params: { date: shiftExportDate, format: 'csv' }, + }); + assert.deepEqual(capturedApiGets[1], { + url: '/reports/x-report/export', + params: { date: shiftExportDate, format: 'xlsx' }, + }); + assert.equal(capturedDownloads[0].filename, `x-report-${shiftExportDate}.csv`); + assert.ok(capturedDownloads[0].text.includes('summary,Gross Sales,,,,385\n')); + assert.equal(capturedDownloads[1].filename, `x-report-${shiftExportDate}.xlsx`); + assert.deepEqual(capturedPdfExports[0], { + endpoint: '/reports/x-report/export', + params: { date: shiftExportDate }, + filename: `x-report-${shiftExportDate}.pdf`, + }); + + // 2) Specific staff shift selected -> passes session_id and shift-scoped filename across csv, xlsx, pdf + model.setSelectedSessionId(shift1Id); + model = renderUseCashClose(); + assert.equal(model.selectedSessionId, shift1Id); + + await model.exportReport('x', 'csv'); + await model.exportReport('x', 'xlsx'); + await model.exportReport('x', 'pdf'); + await Promise.all(pendingDownloadReads.splice(0)); + + assert.deepEqual(capturedApiGets[2], { + url: '/reports/x-report/export', + params: { date: shiftExportDate, session_id: shift1Id, format: 'csv' }, + }); + assert.deepEqual(capturedApiGets[3], { + url: '/reports/x-report/export', + params: { date: shiftExportDate, session_id: shift1Id, format: 'xlsx' }, + }); + assert.equal( + capturedDownloads[2].filename, + `x-report-${shiftExportDate}-shift-${shift1Id}.csv`, + ); + assert.ok( + capturedDownloads[2].text.includes( + `summary,Shift,,,,Shift #${shift1Id} - Cashier User\n`, + ), + ); + assert.ok(capturedDownloads[2].text.includes('summary,Gross Sales,,,,220\n')); + assert.equal( + capturedDownloads[3].filename, + `x-report-${shiftExportDate}-shift-${shift1Id}.xlsx`, + ); + assert.deepEqual(capturedPdfExports[1], { + endpoint: '/reports/x-report/export', + params: { date: shiftExportDate, session_id: shift1Id }, + filename: `x-report-${shiftExportDate}-shift-${shift1Id}.pdf`, + }); + } finally { + Module._load = priorLoad; + } + } + } + if ( !isRealElectronRuntime && typeof rawElectron === 'string' &&