From cbd9047d0ebe952db317ecf9ebe54544be90b8e3 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 01:29:48 -0400 Subject: [PATCH 1/9] fix(reports): scope shift X exports and split tax - Allocate cross-shift paid bill historical tax components proportionally across contributing cash sessions with deterministic minor-unit remainder reconciliation in computeSessionAggregates without recomputing from live tax settings. - Pass selectedSessionId from useCashClose.exportReport and scope GET /api/reports/x-report/export (CSV, XLSX, HTML/PDF) with matching shift validation, authorization, shift labels, and shift filenames. - Add cross-shift tax and shift-scoped export integration/hook regression tests and update API/permissions docs. --- docs/reference/api.md | 2 +- docs/reference/roles-and-permissions.md | 8 +- frontend/src/hooks/useCashClose.ts | 16 +- main/routes/bills.ts | 2 +- main/routes/cash-closures.ts | 212 ++++++++++- main/routes/cash-sessions.ts | 25 +- main/routes/reports.ts | 217 +++++++---- main/services/cash-close-export.ts | 37 +- tests/cash-sessions.test.ts | 330 ++++++++++++++++ tests/reports-export.test.ts | 487 ++++++++++++++++++++++++ 10 files changed, 1215 insertions(+), 121 deletions(-) diff --git a/docs/reference/api.md b/docs/reference/api.md index 75a24664d..f0dda81a4 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 header and `Summary` rows, with the same `400 session_id_invalid`, `404 session_unavailable`, and `403 shift_access_forbidden` checks as `GET /x-report`). 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..da027237b 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -156,10 +156,10 @@ 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 +only view, preview, close, attach explicit drawer movements to, or read/export 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=` and +`GET /api/reports/x-report/export?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 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..11a9567f3 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -59,10 +59,19 @@ 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, + resolveTaxComponents, +} from '../services/tax-components'; import { Z_REPORT_LANGUAGE_POLICY_KEY, parseStoredLanguagePolicy, @@ -781,6 +790,95 @@ 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 }; +} + +function mergeSessionTaxComponents(lists: DisplayTaxComponent[][]): DisplayTaxComponent[] { + const merged = new Map(); + for (const list of lists) { + for (const comp of list) { + const rateKey = comp.rate === null ? '' : new Decimal(comp.rate).toString(); + const key = `${comp.title}\u0000${rateKey}`; + const current = merged.get(key); + if (current) { + current.amount = current.amount.plus(comp.amount); + } else { + merged.set(key, { + title: comp.title, + rate: comp.rate === null ? null : new Decimal(comp.rate).toNumber(), + amount: new Decimal(comp.amount), + }); + } + } + } + return Array.from(merged.values()).map((entry) => ({ + title: entry.title, + rate: entry.rate, + amount: entry.amount.toDecimalPlaces(6).toNumber(), + })); +} + +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 @@ -977,7 +1075,115 @@ 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, + json_extract(je.value, '$.cash_session_id') AS raw_session_id, + COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) AS line_time + FROM bills b + JOIN json_each(CASE + WHEN json_valid(b.payment_details) AND json_type(b.payment_details) = 'array' THEN b.payment_details + WHEN json_valid(b.payment_details) THEN json_array(b.payment_details) + ELSE '[]' + END) je + WHERE b.id = ? AND json_type(je.value) = 'object' + ) + SELECT + raw_amount, + CASE + WHEN raw_session_id IS NOT NULL THEN CAST(raw_session_id AS INTEGER) + WHEN line_time >= datetime(?) AND line_time < datetime(?) THEN ? + ELSE COALESCE( + ( + SELECT cs.id + FROM cash_sessions cs + WHERE cs.id != ? + AND line_time >= datetime(cs.opened_at) + AND (cs.closed_at IS NULL OR line_time < datetime(cs.closed_at)) + ORDER BY cs.opened_at DESC, cs.id DESC + LIMIT 1 + ), + ? + ) + END AS resolved_session_id + FROM lines + ORDER BY line_index ASC + `, + ); + const perBillComponents = bills.map((bill, billIndex) => { + const doc = taxDocuments[billIndex]; + const paymentLines = billPaymentLinesStmt.all( + bill.id, + session.opened_at, + end, + session.id, + session.id, + NO_CASH_SESSION_ID, + ) as Array<{ raw_amount: unknown; resolved_session_id: number }>; + + const buckets = new Map(); + for (const line of paymentLines) { + 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; + } + } + const sessionKey = Number(line.resolved_session_id); + buckets.set(sessionKey, (buckets.get(sessionKey) ?? 0) + amountMinor); + } + + if (!buckets.has(session.id)) return []; + if (buckets.size === 1) return resolveTaxComponents(doc); + + const docComponents = resolveTaxComponents(doc); + if (docComponents.length === 0) return []; + + const sessionIds = Array.from(buckets.keys()); + const weights = Array.from(buckets.values()); + const sessionIndex = sessionIds.indexOf(session.id); + const { compMinors, totalMinor } = roundComponentsToMinorUnits(docComponents, minorFactor); + const entries = compMinors.map((minor) => ({ + allocations: allocateSignedMinorUnits(minor, weights), + })); + if (entries.length > 1) { + const targetSessionMinors = allocateSignedMinorUnits(totalMinor, weights); + reconcileSnapshotAllocations(entries, targetSessionMinors); + } + + const allocated: DisplayTaxComponent[] = []; + for (let i = 0; i < docComponents.length; i++) { + const minorShare = entries[i].allocations[sessionIndex]; + 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 = mergeSessionTaxComponents(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/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index b861b943d..ffc75e603 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -3344,6 +3344,336 @@ 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); + } + } + }); } 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..7eeadbf25 100644 --- a/tests/reports-export.test.ts +++ b/tests/reports-export.test.ts @@ -1488,6 +1488,493 @@ 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 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) => { + capturedDownloads.push({ filename, text: '' }); + void blob.text().then((t) => { + capturedDownloads[capturedDownloads.length - 1].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 new Promise((resolve) => setTimeout(resolve, 10)); + + 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 new Promise((resolve) => setTimeout(resolve, 10)); + + 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' && From 159745e1ef8ab56986a40ae6f8c24ded023c22ee Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 02:24:16 -0400 Subject: [PATCH 2/9] no-mistakes(document): docs(reports): document cross-shift tax split and shift exports --- docs/architecture/business-time.md | 3 ++- docs/architecture/taxation.md | 15 +++++++++++---- docs/reference/api.md | 2 +- main/routes/cash-closures.ts | 8 +++----- 4 files changed, 17 insertions(+), 11 deletions(-) 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..3791e6fd4 100644 --- a/docs/architecture/taxation.md +++ b/docs/architecture/taxation.md @@ -182,10 +182,17 @@ 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()`) allocate a cross-shift paid bill's resolved components across the +contributing sessions proportionally to each session's collected payment share in minor units +(`allocateSignedMinorUnits()` and `reconcileSnapshotAllocations()`), preserving the bill's exact tax +total across shifts. ## Bundled packs diff --git a/docs/reference/api.md b/docs/reference/api.md index f0dda81a4..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`), 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 header and `Summary` rows, with the same `400 session_id_invalid`, `404 session_unavailable`, and `403 shift_access_forbidden` checks as `GET /x-report`). 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` | `/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/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index 11a9567f3..f5d360512 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -711,7 +711,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 }, @@ -1012,10 +1012,8 @@ 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 across contributing shifts by collected payment share. type TaxBillRow = { id: number; order_id: number; From 5540eaad1ff240ee2a796fa074c81174ed1e39dc Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 03:09:06 -0400 Subject: [PATCH 3/9] fix(reports): conserve shift Z tax on partial pay Include partially paid bills in session X/Z tax aggregation and allocate historical tax components sequentially across payment lines against the remaining bill balance in minor units so shifts closed before settlement retain their collected tax share. Resolve untagged legacy payment lines deterministically across overlapping shifts via cash_sessions independent of the caller session, and reuse mergeDisplayTaxComponents from tax-components.ts. --- docs/architecture/taxation.md | 9 +- main/routes/cash-closures.ts | 209 +++++++++------ main/services/tax-components.ts | 125 +++++---- tests/cash-sessions.test.ts | 456 ++++++++++++++++++++++++++++++++ 4 files changed, 664 insertions(+), 135 deletions(-) diff --git a/docs/architecture/taxation.md b/docs/architecture/taxation.md index 3791e6fd4..f5e5c397f 100644 --- a/docs/architecture/taxation.md +++ b/docs/architecture/taxation.md @@ -189,10 +189,11 @@ derives components item by item from stored snapshots and breakdowns so a bill m 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()`) allocate a cross-shift paid bill's resolved components across the -contributing sessions proportionally to each session's collected payment share in minor units -(`allocateSignedMinorUnits()` and `reconcileSnapshotAllocations()`), preserving the bill's exact tax -total across shifts. +(`computeSessionAggregates()`) include partially paid and settled bills and 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 diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index f5d360512..c27e31313 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -70,6 +70,7 @@ import { getHttpRequestSignal } from '../shutdown'; import { DisplayTaxComponent, aggregateTaxComponents, + mergeDisplayTaxComponents, resolveTaxComponents, } from '../services/tax-components'; import { @@ -828,31 +829,6 @@ function roundComponentsToMinorUnits( return { compMinors, totalMinor }; } -function mergeSessionTaxComponents(lists: DisplayTaxComponent[][]): DisplayTaxComponent[] { - const merged = new Map(); - for (const list of lists) { - for (const comp of list) { - const rateKey = comp.rate === null ? '' : new Decimal(comp.rate).toString(); - const key = `${comp.title}\u0000${rateKey}`; - const current = merged.get(key); - if (current) { - current.amount = current.amount.plus(comp.amount); - } else { - merged.set(key, { - title: comp.title, - rate: comp.rate === null ? null : new Decimal(comp.rate).toNumber(), - amount: new Decimal(comp.amount), - }); - } - } - } - return Array.from(merged.values()).map((entry) => ({ - title: entry.title, - rate: entry.rate, - amount: entry.amount.toDecimalPlaces(6).toNumber(), - })); -} - export function computeSessionAggregates( db: ReturnType, session: { id: number; opened_at: string; opening_float_cents: number }, @@ -1013,10 +989,12 @@ export function computePeriodAggregates( .all(start, end) as StaffSalesRow[]); // Day scope aggregates paid bills in [start, end); session scope allocates stored - // historical tax components across contributing shifts by collected payment share. + // historical tax components sequentially across payment lines in minor units. type TaxBillRow = { id: number; order_id: number; + total: number | null; + paid_at: string | null; tax_amount: number | null; tax_snapshot: string | null; tax_breakdown: string | null; @@ -1025,9 +1003,9 @@ export function computePeriodAggregates( ? (db .prepare( ` - SELECT b.* + SELECT b.id, b.order_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 + WHERE b.payment_details IS NOT NULL AND EXISTS ( SELECT 1 FROM json_each(CASE @@ -1036,30 +1014,52 @@ export function computePeriodAggregates( ELSE '[]' END) je WHERE json_type(je.value) = 'object' AND ( - CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) = ? + CASE + WHEN NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL + THEN CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) + ELSE COALESCE( + ( + SELECT cs.id + FROM cash_sessions cs + WHERE COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) >= datetime(cs.opened_at) + AND ( + cs.closed_at IS NULL + OR COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) < datetime(cs.closed_at) + ) + ORDER BY datetime(cs.opened_at) DESC, cs.id DESC + LIMIT 1 + ), + ? + ) + END + ) = ? + AND ( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) IS NULL OR ( - json_extract(je.value, '$.cash_session_id') IS NULL - AND COALESCE( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), - datetime(NULLIF(b.paid_at, '')), - datetime(NULLIF(b.created_at, '')) - ) >= datetime(?) - AND COALESCE( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), - datetime(NULLIF(b.paid_at, '')), - datetime(NULLIF(b.created_at, '')) - ) < datetime(?) + CASE + WHEN NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL + THEN datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) <= datetime(?) + ELSE datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) < datetime(?) + END ) ) ) - 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(NO_CASH_SESSION_ID, session.id, end, end) as TaxBillRow[]) : (db .prepare( ` - SELECT b.* + SELECT b.id, b.order_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 @@ -1083,7 +1083,8 @@ export function computePeriodAggregates( SELECT CAST(je.key AS INTEGER) AS line_index, json_extract(je.value, '$.amount') AS raw_amount, - json_extract(je.value, '$.cash_session_id') AS raw_session_id, + NULLIF(json_extract(je.value, '$.cash_session_id'), '') AS raw_session_id, + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) AS raw_ts, COALESCE( datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), datetime(NULLIF(b.paid_at, '')), @@ -1101,37 +1102,37 @@ export function computePeriodAggregates( raw_amount, CASE WHEN raw_session_id IS NOT NULL THEN CAST(raw_session_id AS INTEGER) - WHEN line_time >= datetime(?) AND line_time < datetime(?) THEN ? ELSE COALESCE( ( SELECT cs.id FROM cash_sessions cs - WHERE cs.id != ? - AND line_time >= datetime(cs.opened_at) + WHERE line_time >= datetime(cs.opened_at) AND (cs.closed_at IS NULL OR line_time < datetime(cs.closed_at)) - ORDER BY cs.opened_at DESC, cs.id DESC + ORDER BY datetime(cs.opened_at) DESC, cs.id DESC LIMIT 1 ), ? ) - END AS resolved_session_id + END AS resolved_session_id, + CASE + WHEN raw_ts IS NULL THEN 1 + WHEN raw_session_id IS NOT NULL AND raw_ts <= datetime(?) THEN 1 + WHEN raw_session_id IS NULL AND raw_ts < datetime(?) THEN 1 + ELSE 0 + END AS within_end FROM lines ORDER BY line_index ASC `, ); const perBillComponents = bills.map((bill, billIndex) => { const doc = taxDocuments[billIndex]; - const paymentLines = billPaymentLinesStmt.all( - bill.id, - session.opened_at, - end, - session.id, - session.id, - NO_CASH_SESSION_ID, - ) as Array<{ raw_amount: unknown; resolved_session_id: number }>; + const rawLines = billPaymentLinesStmt.all(bill.id, NO_CASH_SESSION_ID, end, end) as Array<{ + raw_amount: unknown; + resolved_session_id: number; + within_end: number; + }>; - const buckets = new Map(); - for (const line of paymentLines) { + const paymentLines = rawLines.map((line) => { let amountMinor = 0; if (typeof line.raw_amount === 'number' || typeof line.raw_amount === 'string') { try { @@ -1146,31 +1147,91 @@ export function computePeriodAggregates( amountMinor = 0; } } - const sessionKey = Number(line.resolved_session_id); - buckets.set(sessionKey, (buckets.get(sessionKey) ?? 0) + amountMinor); + return { + amountMinor, + resolvedSessionId: Number(line.resolved_session_id), + withinEnd: Number(line.within_end) === 1, + }; + }); + + const ownedWithinEndLines = paymentLines.filter( + (line) => line.resolvedSessionId === session.id && line.withinEnd, + ); + 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 (!buckets.has(session.id)) return []; - if (buckets.size === 1) return resolveTaxComponents(doc); + if ( + paymentLines.length === 1 && + ownedWithinEndLines.length === 1 && + (isSettled || (billTotalMinor > 0 && ownedMinorSum >= billTotalMinor)) + ) { + return resolveTaxComponents(doc); + } const docComponents = resolveTaxComponents(doc); if (docComponents.length === 0) return []; - const sessionIds = Array.from(buckets.keys()); - const weights = Array.from(buckets.values()); - const sessionIndex = sessionIds.indexOf(session.id); const { compMinors, totalMinor } = roundComponentsToMinorUnits(docComponents, minorFactor); - const entries = compMinors.map((minor) => ({ - allocations: allocateSignedMinorUnits(minor, weights), - })); - if (entries.length > 1) { - const targetSessionMinors = allocateSignedMinorUnits(totalMinor, weights); - reconcileSnapshotAllocations(entries, targetSessionMinors); + 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.resolvedSessionId === session.id && line.withinEnd) { + 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 = entries[i].allocations[sessionIndex]; + const minorShare = sessionCompMinors[i]; if (minorShare === 0 && compMinors[i] !== 0) continue; allocated.push({ title: docComponents[i].title, @@ -1180,7 +1241,7 @@ export function computePeriodAggregates( } return allocated; }); - taxComponents = mergeSessionTaxComponents(perBillComponents); + taxComponents = mergeDisplayTaxComponents(perBillComponents); } const grossCollectedCents = Math.round(Number(billRow.gross_collected || 0) * minorFactor); 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 ffc75e603..f4106ef7d 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -3674,6 +3674,462 @@ async function main() { } } }); + + // 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 a retained legacy partial installment 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 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 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: 4400 }), + ); + 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: 11000 }), + ); + 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: 11000, + }), + ); + 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 && + closeMigA.status === 200 && + closeMigB.status === 200 && + closeMigDay.status === 201 && + sumCompAmts(zMigA) === 0 && + findCompAmt(zMigB, 'VAT', 10) === 10 && + sumCompAmts(zMigA) + sumCompAmts(zMigB) === sumCompAmts(zMigDay), + `Section 13c (ii): retained legacy partial installment settled via POST /api/bills/:id/payment conserves exact tax across overlapping shift Z snapshots without duplication (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 { xSubCent, closeSubCent } = 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 cRes = await request(app) + .post(`/api/cash-sessions/${subCentShift}/close`) + .set('Authorization', `Bearer ${s11CashierAToken}`) + .send({ counted_cash_cents: 0 }); + return { xSubCent: xRes, closeSubCent: cRes }; + }, + ); + const zSubCent = readStoredSessionTax(closeSubCent.body.closure_id); + assert( + xSubCent.status === 200 && + closeSubCent.status === 200 && + findCompAmt(xSubCent.body?.xReport?.taxComponents, 'VAT', 10) === 0.005 && + findCompAmt(zSubCent, 'VAT', 10) === 0.005, + `Section 13d: single-session paid bill preserves exact sub-cent historical tax precision 0.005 (x=${JSON.stringify(xSubCent.body?.xReport?.taxComponents)}, z=${JSON.stringify(zSubCent)})`, + ); + } } 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(); From 8978cc6fad83a678357e7197e9feef2764c8a651 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 03:37:16 -0400 Subject: [PATCH 4/9] no-mistakes(review): {"summary":"fix(reports): align shift tax ownership and export test"} --- main/routes/cash-closures.ts | 28 ++++---- tests/cash-sessions.test.ts | 121 +++++++++++++++++++++++++++++++++-- tests/reports-export.test.ts | 16 +++-- 3 files changed, 137 insertions(+), 28 deletions(-) diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index c27e31313..de178672d 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -1042,20 +1042,18 @@ export function computePeriodAggregates( END ) = ? AND ( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) IS NULL - OR ( - CASE - WHEN NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL - THEN datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) <= datetime(?) - ELSE datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) < datetime(?) - END - ) + NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL + OR COALESCE( + datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), + datetime(NULLIF(b.paid_at, '')), + datetime(NULLIF(b.created_at, '')) + ) < datetime(?) ) ) ORDER BY COALESCE(b.paid_at, b.created_at), b.id `, ) - .all(NO_CASH_SESSION_ID, session.id, end, end) as TaxBillRow[]) + .all(NO_CASH_SESSION_ID, session.id, end) as TaxBillRow[]) : (db .prepare( ` @@ -1084,7 +1082,6 @@ export function computePeriodAggregates( 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, - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')) AS raw_ts, COALESCE( datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), datetime(NULLIF(b.paid_at, '')), @@ -1115,9 +1112,8 @@ export function computePeriodAggregates( ) END AS resolved_session_id, CASE - WHEN raw_ts IS NULL THEN 1 - WHEN raw_session_id IS NOT NULL AND raw_ts <= datetime(?) THEN 1 - WHEN raw_session_id IS NULL AND raw_ts < datetime(?) THEN 1 + WHEN raw_session_id IS NOT NULL THEN 1 + WHEN line_time < datetime(?) THEN 1 ELSE 0 END AS within_end FROM lines @@ -1126,7 +1122,7 @@ export function computePeriodAggregates( ); const perBillComponents = bills.map((bill, billIndex) => { const doc = taxDocuments[billIndex]; - const rawLines = billPaymentLinesStmt.all(bill.id, NO_CASH_SESSION_ID, end, end) as Array<{ + const rawLines = billPaymentLinesStmt.all(bill.id, NO_CASH_SESSION_ID, end) as Array<{ raw_amount: unknown; resolved_session_id: number; within_end: number; @@ -1181,8 +1177,8 @@ export function computePeriodAggregates( const ownedMinorSum = ownedWithinEndLines.reduce((sum, line) => sum + line.amountMinor, 0); if ( - paymentLines.length === 1 && - ownedWithinEndLines.length === 1 && + paymentLines.length > 0 && + ownedWithinEndLines.length === paymentLines.length && (isSettled || (billTotalMinor > 0 && ownedMinorSum >= billTotalMinor)) ) { return resolveTaxComponents(doc); diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index f4106ef7d..58d59a3a6 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -4108,26 +4108,135 @@ async function main() { ]), subCentBill, ); - const { xSubCent, closeSubCent } = await withFixedClock( + 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: 0 }); - return { xSubCent: xRes, closeSubCent: cRes }; + .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( - xSubCent.status === 200 && + 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(zSubCent, 'VAT', 10) === 0.005, - `Section 13d: single-session paid bill preserves exact sub-cent historical tax precision 0.005 (x=${JSON.stringify(xSubCent.body?.xReport?.taxComponents)}, z=${JSON.stringify(zSubCent)})`, + 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)})`, ); } } finally { diff --git a/tests/reports-export.test.ts b/tests/reports-export.test.ts index 7eeadbf25..4c833a827 100644 --- a/tests/reports-export.test.ts +++ b/tests/reports-export.test.ts @@ -1757,6 +1757,7 @@ async function run(): Promise { 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; @@ -1866,10 +1867,13 @@ async function run(): Promise { if (reqName === '@/lib/download') { return { downloadBlob: (blob: Blob, filename: string) => { - capturedDownloads.push({ filename, text: '' }); - void blob.text().then((t) => { - capturedDownloads[capturedDownloads.length - 1].text = t; - }); + const entry = { filename, text: '' }; + capturedDownloads.push(entry); + pendingDownloadReads.push( + blob.text().then((t) => { + entry.text = t; + }), + ); }, }; } @@ -1913,7 +1917,7 @@ async function run(): Promise { await model.exportReport('x', 'csv'); await model.exportReport('x', 'xlsx'); await model.exportReport('x', 'pdf'); - await new Promise((resolve) => setTimeout(resolve, 10)); + await Promise.all(pendingDownloadReads.splice(0)); assert.deepEqual(capturedApiGets[0], { url: '/reports/x-report/export', @@ -1940,7 +1944,7 @@ async function run(): Promise { await model.exportReport('x', 'csv'); await model.exportReport('x', 'xlsx'); await model.exportReport('x', 'pdf'); - await new Promise((resolve) => setTimeout(resolve, 10)); + await Promise.all(pendingDownloadReads.splice(0)); assert.deepEqual(capturedApiGets[2], { url: '/reports/x-report/export', From 98f7770ab2a05f761b1e8fb43b1bb80b09c0d4f8 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 04:08:44 -0400 Subject: [PATCH 5/9] no-mistakes(review): {"summary":"fix(reports): include split_group_id in closure tax queries"} --- main/routes/cash-closures.ts | 5 +- tests/cash-sessions.test.ts | 191 +++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index de178672d..ec72afe4f 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -993,6 +993,7 @@ export function computePeriodAggregates( type TaxBillRow = { id: number; order_id: number; + split_group_id: string | null; total: number | null; paid_at: string | null; tax_amount: number | null; @@ -1003,7 +1004,7 @@ export function computePeriodAggregates( ? (db .prepare( ` - SELECT b.id, b.order_id, b.total, b.paid_at, b.tax_amount, b.tax_snapshot, b.tax_breakdown + 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.payment_details IS NOT NULL AND EXISTS ( @@ -1057,7 +1058,7 @@ export function computePeriodAggregates( : (db .prepare( ` - SELECT b.id, b.order_id, b.total, b.paid_at, b.tax_amount, b.tax_snapshot, b.tax_breakdown + 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 diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index 58d59a3a6..6b4c6f8f7 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -4238,6 +4238,197 @@ async function main() { 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); + } + } } } finally { db.prepare(`UPDATE settings SET value = 'false' WHERE key = 'one_shift_per_staff'`).run(); From 99eba4992b21cc28b2e82e824d9f88ef4bad3d52 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 05:04:06 -0400 Subject: [PATCH 6/9] no-mistakes(document): docs(reports): clarify single-shift vs cross-shift tax attribution --- docs/architecture/taxation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture/taxation.md b/docs/architecture/taxation.md index f5e5c397f..70da98ae9 100644 --- a/docs/architecture/taxation.md +++ b/docs/architecture/taxation.md @@ -189,7 +189,8 @@ derives components item by item from stored snapshots and breakdowns so a bill m 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 partially paid and settled bills and allocate resolved +(`computeSessionAggregates()`) include partially paid and settled bills: 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 From 14d75c6607ff5cb79d7b54cd650f6f11267fc2ff Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 10:23:03 -0400 Subject: [PATCH 7/9] fix(reports): anchor legacy installment time across settlement --- main/routes/cash-closures.ts | 61 +++++----- tests/cash-sessions.test.ts | 215 +++++++++++++++++++++++++++++++++-- 2 files changed, 235 insertions(+), 41 deletions(-) diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index ec72afe4f..dbe2db3f2 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -523,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) @@ -547,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 @@ -657,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 @@ -735,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 @@ -942,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 @@ -1022,18 +1031,10 @@ export function computePeriodAggregates( ( SELECT cs.id FROM cash_sessions cs - WHERE COALESCE( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), - datetime(NULLIF(b.paid_at, '')), - datetime(NULLIF(b.created_at, '')) - ) >= datetime(cs.opened_at) + WHERE ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} >= datetime(cs.opened_at) AND ( cs.closed_at IS NULL - OR COALESCE( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), - datetime(NULLIF(b.paid_at, '')), - datetime(NULLIF(b.created_at, '')) - ) < datetime(cs.closed_at) + OR ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} < datetime(cs.closed_at) ) ORDER BY datetime(cs.opened_at) DESC, cs.id DESC LIMIT 1 @@ -1044,11 +1045,7 @@ export function computePeriodAggregates( ) = ? AND ( NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL - OR COALESCE( - datetime(NULLIF(json_extract(je.value, '$.timestamp'), '')), - datetime(NULLIF(b.paid_at, '')), - datetime(NULLIF(b.created_at, '')) - ) < datetime(?) + OR ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} < datetime(?) ) ) ORDER BY COALESCE(b.paid_at, b.created_at), b.id @@ -1083,11 +1080,7 @@ export function computePeriodAggregates( 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, - 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 diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index 6b4c6f8f7..b814b3894 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -3995,7 +3995,7 @@ async function main() { `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 a retained legacy partial installment via POST /api/bills/:id/payment + // Section 13c (ii): overlapping shifts settling retained legacy partial installments via POST /api/bills/:id/payment const legacyPartialBill = seedTaxBill( 'LEGACY-PARTIAL-1', 100, @@ -4031,23 +4031,44 @@ async function main() { ).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: 4400 }), + .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: 11000 }), + .send({ counted_cash_cents: 6600 }), ); const closeMigDay = await withFixedClock('2026-11-18T13:00:00.000Z', async () => request(app) @@ -4056,7 +4077,7 @@ async function main() { .send({ business_date: '2026-11-18', opening_float_cents: 0, - counted_cash_cents: 11000, + counted_cash_cents: 6600, }), ); const zMigA = readStoredSessionTax(closeMigA.body.closure_id); @@ -4064,13 +4085,17 @@ async function main() { 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) === 10 && - sumCompAmts(zMigA) + sumCompAmts(zMigB) === sumCompAmts(zMigDay), - `Section 13c (ii): retained legacy partial installment settled via POST /api/bills/:id/payment conserves exact tax across overlapping shift Z snapshots without duplication (zA=${JSON.stringify(zMigA)}, zB=${JSON.stringify(zMigB)}, day=${JSON.stringify(zMigDay)})`, + 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 @@ -4429,6 +4454,182 @@ async function main() { 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)})`, + ); } } finally { db.prepare(`UPDATE settings SET value = 'false' WHERE key = 'one_shift_per_staff'`).run(); From aacb738473b99df9b1f2cab92c08d93512c2ecc7 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 11:08:56 -0400 Subject: [PATCH 8/9] no-mistakes(review): {"summary":"fix(reports): exclude cancelled unsettled bills and bound scans"} --- main/routes/cash-closures.ts | 85 +++++++------- tests/cash-sessions.test.ts | 221 +++++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 39 deletions(-) diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index dbe2db3f2..cd6dd1b5a 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -1015,7 +1015,12 @@ export function computePeriodAggregates( ` 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 + 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 @@ -1024,10 +1029,15 @@ export function computePeriodAggregates( ELSE '[]' END) je WHERE json_type(je.value) = 'object' AND ( - CASE - WHEN NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL - THEN CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) - ELSE COALESCE( + ( + NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL + AND CAST(json_extract(je.value, '$.cash_session_id') AS INTEGER) = ? + ) + OR ( + 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( ( SELECT cs.id FROM cash_sessions cs @@ -1040,18 +1050,14 @@ export function computePeriodAggregates( LIMIT 1 ), ? - ) - END - ) = ? - AND ( - NULLIF(json_extract(je.value, '$.cash_session_id'), '') IS NOT NULL - OR ${LEGACY_STABLE_PAYMENT_LINE_TIME_SQL} < datetime(?) + ) = ? + ) ) ) ORDER BY COALESCE(b.paid_at, b.created_at), b.id `, ) - .all(NO_CASH_SESSION_ID, session.id, end) as TaxBillRow[]) + .all(session.id, session.opened_at, end, NO_CASH_SESSION_ID, session.id) as TaxBillRow[]) : (db .prepare( ` @@ -1092,35 +1098,39 @@ export function computePeriodAggregates( SELECT raw_amount, CASE - WHEN raw_session_id IS NOT NULL THEN CAST(raw_session_id AS INTEGER) - ELSE 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 - ), - ? - ) - END AS resolved_session_id, - CASE - WHEN raw_session_id IS NOT NULL THEN 1 - WHEN line_time < datetime(?) THEN 1 + 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 within_end + 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, NO_CASH_SESSION_ID, end) as Array<{ - raw_amount: unknown; - resolved_session_id: number; - within_end: number; - }>; + 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; @@ -1139,14 +1149,11 @@ export function computePeriodAggregates( } return { amountMinor, - resolvedSessionId: Number(line.resolved_session_id), - withinEnd: Number(line.within_end) === 1, + ownedWithinEnd: Number(line.owned_within_end) === 1, }; }); - const ownedWithinEndLines = paymentLines.filter( - (line) => line.resolvedSessionId === session.id && line.withinEnd, - ); + const ownedWithinEndLines = paymentLines.filter((line) => line.ownedWithinEnd); if (ownedWithinEndLines.length === 0) return []; const sumAllLineMinors = paymentLines.reduce((sum, line) => sum + line.amountMinor, 0); @@ -1212,7 +1219,7 @@ export function computePeriodAggregates( remainingPoolMinor -= lineMinor; } - if (line.resolvedSessionId === session.id && line.withinEnd) { + if (line.ownedWithinEnd) { for (let i = 0; i < docComponents.length; i++) { sessionCompMinors[i] += lineShares[i]; } diff --git a/tests/cash-sessions.test.ts b/tests/cash-sessions.test.ts index b814b3894..a9bc79bbb 100644 --- a/tests/cash-sessions.test.ts +++ b/tests/cash-sessions.test.ts @@ -4630,6 +4630,227 @@ async function main() { 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(); From 6664f6fea3095e40a6be40cdea30cf38c152e201 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Thu, 29 Oct 2026 11:51:47 -0400 Subject: [PATCH 9/9] no-mistakes(document): docs(reports): align shift tax scope and deduplicate references --- docs/architecture/taxation.md | 8 +++++--- docs/reference/roles-and-permissions.md | 17 +++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/architecture/taxation.md b/docs/architecture/taxation.md index 70da98ae9..a25d60de5 100644 --- a/docs/architecture/taxation.md +++ b/docs/architecture/taxation.md @@ -189,7 +189,8 @@ derives components item by item from stored snapshots and breakdowns so a bill m 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 partially paid and settled bills: a bill settled wholly within +(`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()`), @@ -327,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/roles-and-permissions.md b/docs/reference/roles-and-permissions.md index da027237b..2126c5ca9 100644 --- a/docs/reference/roles-and-permissions.md +++ b/docs/reference/roles-and-permissions.md @@ -157,14 +157,15 @@ 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/export 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=` and -`GET /api/reports/x-report/export?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). +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