Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/architecture/business-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 17 additions & 6 deletions docs/architecture/taxation.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,20 @@ reinterpreted as a per-line amount.
Refunds read the snapshot. Recomputing tax at refund time would produce a different answer if the
pack had been updated in between, which is the exact failure the snapshot exists to prevent.

The reporting counterpart is `GET /api/reports/tax-components`, backed by
[`main/services/tax-components.ts`](../../main/services/tax-components.ts). It derives components
item by item so a bill that mixes categorized and uncategorized lines cannot double-count the
categorized portion against the bill-level `tax_breakdown`.
The reporting counterparts are `GET /api/reports/tax-components` and X/Z cash-closure reporting in
[`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts), both backed by
[`main/services/tax-components.ts`](../../main/services/tax-components.ts). `resolveTaxComponents()`
derives components item by item from stored snapshots and breakdowns so a bill mixing categorized and
uncategorized lines cannot double-count the categorized portion against the bill-level
`tax_breakdown`, and never recomputes historical tax from live tax settings. Whole-day X/Z reports
aggregate paid bills in the day window through `aggregateTaxComponents()`. Session-scoped X/Z reports
(`computeSessionAggregates()`) include settled bills and partially paid bills on non-cancelled
orders: a bill settled wholly within
one shift keeps `resolveTaxComponents()` as-is, while partial or cross-shift bills allocate resolved
components sequentially across payment lines against the remaining bill total in minor units
(`allocateSignedMinorUnits()`, `reconcileSnapshotAllocations()`, and `mergeDisplayTaxComponents()`),
so a shift closed before settlement keeps its collected tax share in its immutable Z snapshot and
subsequent shifts receive the exact remaining tax.

## Bundled packs

Expand Down Expand Up @@ -318,8 +328,9 @@ author cannot rely on any of them.
- **`storeId` on a pack.** No pack schema field references a store.
- **Customer tax fields.** A customer carries a registration number, state code, and exempt flag
through `TaxCustomer`, and nothing tax-address-shaped beyond that.
- **Reporting by anything other than tax component.** The report surface is
`GET /api/reports/tax-components`. There is no reporting by rule, by category, or by jurisdiction.
- **Reporting by anything other than tax component.** Tax reporting surfaces (such as
`GET /api/reports/tax-components` and X/Z cash-closure reports) group only by resolved tax
component; there is no reporting by rule, by category, or by jurisdiction.

## Verification

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<date>.<format>"`. |
| `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 #<id> - <staff>` 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-<date>.<format>"` (or `attachment; filename="x-report-<shiftDate>-shift-<id>.<format>"` 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<z_number>-<date>.<format>"`. |
| `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). |
Expand Down
19 changes: 10 additions & 9 deletions docs/reference/roles-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,16 @@ every save.

**Cash shifts.** Opening, viewing, and closing shifts are gated by `cash.shifts.open`,
`cash.shifts.view`, and `cash.shifts.close`. Non-`owner`/`manager` roles (such as `cashier`) may
only view, preview, close, attach explicit drawer movements to, or read the shift X-report for the
shift they opened (`403 session_owner_required` on shift/movement routes and `403
shift_access_forbidden` on `GET /api/reports/x-report?session_id=` when targeting another user's
shift), whereas an `owner` or `manager` can inspect and close any staff member's shift, with the
closing actor recorded on the closure row. On `POST /api/cash-sessions/:id/close` and
`POST /api/cash-closures/movements`, the own-session rule is enforced inside the write transaction,
where the stored session and the actor are read together. This is a context policy, not
a permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md)
and [business time](../architecture/business-time.md#cash-closures).
only view, preview, close, attach explicit drawer movements to, or read/export the shift X-report
for the shift they opened (`403` when targeting another user's shift), whereas an `owner` or
`manager` can inspect, export, and close any staff member's shift, with the closing actor recorded
on the closure row. On `POST /api/cash-sessions/:id/close` and `POST /api/cash-closures/movements`,
the own-session rule is enforced inside the write transaction, where the stored session and the
actor are read together. This is a context policy, not a permission - see
[the authentication and authorization page](../architecture/authentication-and-authorization.md),
[business time](../architecture/business-time.md#cash-closures), and per-route error codes in
[Cash sessions](api.md#cash-sessions), [Cash closures](api.md#cash-closures), and
[Reports](api.md#reports).

**Order cancellation and completion overrides.** Staff holding `orders.status.update` can cancel a
whole order without a PIN only while the order and all of its items are `pending` and no bill holds
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/hooks/useCashClose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion main/routes/bills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,7 +1357,7 @@ interface TaxSnapshotAllocationResult {
exclusiveTaxMinors: number[] | null;
}

function reconcileSnapshotAllocations(
export function reconcileSnapshotAllocations(
entries: Array<{ allocations: number[] }>,
targets: number[],
): void {
Expand Down
Loading
Loading