a9ccf9e20c
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
351 lines
24 KiB
Markdown
351 lines
24 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, domain, business, shifts, anti-fraud]
|
||
sources: []
|
||
updated: 2026-09-05
|
||
status: open
|
||
---
|
||
|
||
# Shift (manned mode) & the Z-Report
|
||
|
||
A **shift** is one operator's accountability period at a manned booth: from the moment they take
|
||
over to the moment they hand over, however long that is. At the end, the system signs and **prints
|
||
a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
|
||
|
||
## Shifts exist ONLY in manned mode
|
||
|
||
A shift is fundamentally a **human accountability boundary** — "this person was responsible for the
|
||
takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there
|
||
is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle**
|
||
(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing
|
||
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
||
don't force one model across both.
|
||
|
||
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
|
||
|
||
> **Superseded 2026-09-05 — now PER TILL (built).** With money-taking venue modules (Car Wash
|
||
> at the bay), "site-wide" became **per till**: one open shift and one drawer per till
|
||
> (`booth`, `carwash`, …), each with its own operator, float, vouchers and Z-report; every
|
||
> money event names its till. See §"Tills" below and [[venue-modules]] §"Tills". Everything
|
||
> in this section stays true *within* a till.
|
||
|
||
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
|
||
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
|
||
falls inside exactly one operator's window. Consequences:
|
||
|
||
- **Login ≠ shift.** An operator may log in **off-shift** (e.g. to review their own past activity);
|
||
logging in never opens a shift. Conversely a shift can't be opened by two people at once.
|
||
- **Opening is refused when ANY shift is open** — whether the operator's own (double-open) or
|
||
*another* operator's (handover not done). `ShiftService.open()` checks `currentOpenShift()` (the
|
||
single site-wide open shift = most recent shift event on the whole chain is a `shift_open`), and
|
||
throws `ShiftAlreadyOpenError` carrying `heldBy` so the UI can name who holds it. Operator B can
|
||
only start once operator A closes — that's the handover.
|
||
- **The booth money path is GATED on an open shift.** `/api/pay`, `/api/exit`, `/api/voucher`,
|
||
`/api/barrier/reopen` run a `requireShift` preHandler that 409s `{ code: "no_shift" }` when none
|
||
is open. Read-only lookups (`/api/session/:id`, `/api/sessions/active`, `/api/pay/quote`) stay
|
||
ungated so the modal can still *display* a session and prompt "open a shift". The server is the
|
||
enforcement point; the UI mirrors it (see [[booth-console]]).
|
||
- **"Operate under someone else's shift" is deliberately disallowed.** B's takings would land in A's
|
||
Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open.
|
||
- **Logs are per-shift.** The booth live feed shows only events from the open shift's window
|
||
(`GET /api/events?since=<shiftStart>`); no shift open → no feed, just the "open a shift" prompt.
|
||
|
||
## A shift is NOT time-based
|
||
|
||
It is delimited by **explicit operator action**, never by a clock:
|
||
|
||
- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in
|
||
a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active
|
||
operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout).
|
||
- **Start Shift / End Shift are explicit, and independent of login.** One login can span many
|
||
shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The
|
||
operator (the same person or the next) marks the boundary.
|
||
|
||
```
|
||
login ——————————————————————————————————————————————→ (until logout)
|
||
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
|
||
```
|
||
|
||
## What End Shift does
|
||
|
||
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
||
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
|
||
**[[subscription]] sale fee** an operator collects during the shift (selling/renewing at the booth
|
||
appends a signed `payment` with `subscriptionSale: true`, amount `priceMinor × months` — **built
|
||
2026-06-20**) — it folds into this set like any transient taking, no special-casing. *(Before that
|
||
date subscription sales appended nothing, so the cash was off the Z-report entirely — a real
|
||
[[threat-model]] hole; see [[subscription]] "Collecting the fee".)*
|
||
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
||
(the card line is omitted when there's no terminal).
|
||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
||
startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to
|
||
the prior Z so a missing/out-of-order Z-report is itself visible.
|
||
4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the
|
||
booth printer.
|
||
|
||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||
no variance gate, no manager override.
|
||
|
||
> **Takings split by SOURCE + confirm-before-close (2026-06-21).** Two related changes:
|
||
> 1. The report now splits takings into **Tickets** (transient) vs **Subscriptions** (monthly
|
||
> `subscriptionSale` + a subscriber's out-of-window `subscriptionWindowCharge`), so the operator
|
||
> sees subscriber money apart from ticket money. The buckets are derived from the signed payment
|
||
> payload flags and always reconcile to `cash + card` (a payment with neither flag is a ticket).
|
||
> Computed once in `#summariseWindow`, carried on the signed `shift_z_report` payload
|
||
> (`ticketTotalMinor`/`subscriptionTotalMinor`/`subscriptionSalesMinor`/`subscriptionWindowMinor`),
|
||
> shown in the X-report, the close modal, the history detail, and the printed Z-report; old reports
|
||
> that predate the fields default subscription to 0 (ticket absorbs the whole take).
|
||
>
|
||
> **Display simplified (2026-06-28).** All four figures stay in the SIGNED payload (audit data —
|
||
> untouched), but the operator-facing breakdown was trimmed: the `shitje` (subscription-sales)
|
||
> sub-line was **removed** from the close modal, the X/Z-report views, and the printed slip —
|
||
> `Abonime` is the subscription total, with only the out-of-window part broken out under it (it
|
||
> was confusing to show both halves under a total). The **opening cash** (`openingFloatMinor`,
|
||
> labelled "Arka fillestare") was **added** to the drawer block so `opening + cash-taken = expected
|
||
> drawer` reads explicitly.
|
||
> 2. The **header shift button no longer closes directly** — a stray click would sign an irreversible
|
||
> Z-report. It opens a **confirm modal showing the live X-report** (the source split + expected
|
||
> drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk).
|
||
|
||
> **Z-report is now Albanian (2026-06-19).** The printed Z-report labels were hardcoded English
|
||
> (`Operator:`/`From:`/`Cash:`) with raw ISO timestamps; now fully Albanian (`Operatori`/`Nga`/`Deri`/
|
||
> `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`,
|
||
> shared via `formatStampSq` from [[rongta-printer]]. Consistent with the "[[i18n|printed paper is
|
||
> always Albanian]]" rule — independent of the operator's UI language.
|
||
|
||
### Shift history UI + permission scoping (2026-06-19)
|
||
A read-only **shift-history screen** (`GET /api/shifts`) lists completed shifts — each a signed
|
||
`shift_z_report` folded for its figures (no re-summing), newest-first, expandable to the drawer
|
||
reconciliation. **Scoped server-side by permission** (dynamic [[local-jwt-auth|RBAC]]): an operator
|
||
with `shift:read` sees **only their own** shifts (operator/date filters ignored); an admin-grade role
|
||
(`shift:cash`) sees **all** operators with an operator + date-window filter. The server hard-scopes
|
||
non-admins to `req.user.username` regardless of any `?operator=` param (verified: an operator passing
|
||
another's name returns `scope:self`, 0 rows). One screen, behaviour driven by the returned `scope`.
|
||
|
||
### As-built (2026-06-16)
|
||
|
||
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
|
||
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
|
||
event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
|
||
> **Superseded 2026-06-18:** open-ness is now judged **site-wide** (`currentOpenShift()` — the most
|
||
> recent shift event on the *whole* chain), not per-operator. See "Site-wide single-open" above.
|
||
> `openShiftFor(operator)` survives only for `close()` (you close your own shift).
|
||
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
|
||
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
|
||
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
|
||
printer. Printing is best-effort — a failed print does **not** undo the signed close (the event is
|
||
the record; `printed:false` is returned).
|
||
- **Routes** (`routes/shift.ts`, cashier/operator/admin): `GET /api/shift/current`,
|
||
`POST /api/shift/open` (409 if already open), `POST /api/shift/close` (409 if none open).
|
||
**UI** `ShiftControl` in the app shell (non-readonly): Start/End + the Z-report totals.
|
||
- Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) →
|
||
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||
verifyChain ok.
|
||
|
||
## Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18)
|
||
|
||
The Z-report's payment totals answer "how much did this shift *take*?" — but a manned booth also has a
|
||
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
|
||
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
|
||
|
||
**The events (drawer vouchers — re-modelled 2026-06-20):** the original design used one signed
|
||
**`cash_movement`** event with a *signed* `amountMinor` (+ load / − removal). That conflated two
|
||
distinct financial documents into a `±`. In accounting a pay-in and a pay-out are different vouchers
|
||
(in Albanian: **Mandat Arkëtimi** = receipt, **Mandat Pagese** = disbursement), so the direction now
|
||
lives in the **event type**, not the sign of an amount:
|
||
|
||
- **`cash_in`** (*Mandat Arkëtimi* — a **receipt / pay-IN**): cash enters the drawer. `amountMinor` is
|
||
a **positive magnitude**. Voucher no. `AR-NNNN`.
|
||
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
|
||
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
|
||
- Payload: `{ amountMinor (positive), reason, currency, operator (who recorded), voucherNo }`. Each
|
||
prints a **slip** (Albanian, like every operator-facing paper).
|
||
- **Authorization model (redesigned 2026-07-01): operator RECORDS freely → admin REVIEWS after.** See
|
||
"Drawer review" below. (Superseded the 2026-06-20 *operator-raised / admin-authorized-at-creation*
|
||
scheme, where the operator typed an admin's password inline — that blocked the operator until an
|
||
admin stood at the booth, and it lived on `/shifts`.)
|
||
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
|
||
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
|
||
voucher pair. The append-only chain is never rewritten.
|
||
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
|
||
the drawer).
|
||
|
||
**The math — drawer is a fold over the chain BY TIME, not by operator** (whoever holds the drawer at a
|
||
given instant is accountable for its running balance, regardless of who recorded each movement):
|
||
|
||
```
|
||
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
|
||
+ Σ cash_in amounts (positive) up to `at`
|
||
− Σ cash_out amounts (positive) up to `at`
|
||
+ Σ cash_movement amounts (legacy, signed) up to `at`
|
||
```
|
||
|
||
**`cash_review` is NOT in this fold.** A review decision never moves cash — so it's excluded from the
|
||
drawer math by construction (see "Drawer review").
|
||
|
||
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
|
||
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
|
||
first shift ever opens at **0**; the admin's load makes it 5000.
|
||
|
||
**The Z-report at close** reports the full drawer picture for the shift window `[start, end]`:
|
||
`openingFloat`, `cashTakenMinor` (cash payments in-window), `cashAddedMinor` / `cashRemovedMinor`
|
||
(movements in-window), and `expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved`.
|
||
That `expectedDrawer` is exactly the **next** shift's opening float — the carry-over.
|
||
|
||
**Worked example (the canonical scenario):**
|
||
|
||
| Step | Event | Drawer |
|
||
| --- | --- | --- |
|
||
| Opening day | `cash_in` (Mandat Arkëtimi) +5000 | 5000 |
|
||
| Shift 1 takes 6500 cash | payments | 11500 |
|
||
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
|
||
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
|
||
| `cash_out` (Mandat Pagese) 5000 | withdrawal | 6500 |
|
||
| Shift 2 takes 4500 cash | payments | 11000 |
|
||
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
|
||
| Shift 3 opens | opening float = **11000** | … |
|
||
|
||
Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure
|
||
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
|
||
against it.
|
||
|
||
## Drawer review — operator records freely, admin reviews after (2026-07-01)
|
||
|
||
The drawer feature was reworked from **synchronous admin-authorization-at-creation** (an admin had to
|
||
type their password at the booth for every receipt/disbursement) to **operator-records → admin-reviews-
|
||
after**. This removes the friction while keeping accountability.
|
||
|
||
- **Record (`drawer:create`).** An operator RECORDS a `cash_in`/`cash_out` freely — no admin sign-off
|
||
at creation. It **counts in the drawer immediately** (the cash physically moved). The permission is
|
||
**per-role and admin-revocable** in the Roles UI: an admin can turn off an operator's ability to
|
||
record at all. `POST /api/drawer/movement`.
|
||
- **Review (`drawer:review`, admin-grade).** Each movement is `pending` until an admin **authorizes**
|
||
or **denies** it. The decision is a new **signed `cash_review`** event `{ refId, decision, reviewedBy,
|
||
note? }` — append-only, so the decision itself is auditable. `GET /api/drawer/movements` (operators
|
||
see only their own; reviewers see all + a status filter = the pending queue) and
|
||
`POST /api/drawer/review`. One decision per movement (re-review rejected).
|
||
- **A denial is a FLAG, not a reversal — this is the load-bearing design choice.** Denying a movement
|
||
does **NOT** append a reversing cash event and does **NOT** touch the drawer balance. It's a judgment
|
||
about the operator ("this disbursement wasn't genuine"); crediting/debiting them is the **admin's/
|
||
accountant's job, outside this system**. We deliberately do **not** build accounting here — just a
|
||
simple running balance.
|
||
|
||
> **Why deny ≠ reversal (the cross-shift argument).** The drawer folds BY TIME across shifts. If a
|
||
> denial appended a reversal, it would land in whatever shift is open **when the admin clicks** — which
|
||
> can be a **later** operator's shift, after the reviewed shift already closed and Z-reported. That
|
||
> would make operator 2 accountable for correcting operator 1's mistake. By making review a pure flag,
|
||
> the correction never enters the ledger, so it **cannot leak into the next operator's drawer**. The
|
||
> next operator simply inherits the real physical balance (which they count at shift open) and carries
|
||
> on. This is verified by a regression test (`shift-service.test.ts`: op1 disburses → closes → op2
|
||
> inherits → admin denies → op2's drawer unchanged).
|
||
|
||
- **Home.** The feature moved OFF `/shifts` to its own top-level **`/drawer`** route (operator: record
|
||
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
|
||
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
|
||
|
||
## Drawer hub — the page answers "what's in the till and why" (2026-07-05)
|
||
|
||
Operator feedback: `/drawer` was **too simple** — record + review only, with no current balance, no
|
||
sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub of five
|
||
panels, all reads over data that already existed:
|
||
|
||
- **Drawer now** — the running balance (new `GET /api/drawer/balance`, `shift:read`; a passthrough to
|
||
the service's existing `drawerBalance()`, which was never exposed). While a shift is open, the
|
||
X-report breakdown sits beside it so the number is always explainable: *opening float + cash takings
|
||
(with payment count — the "current shift incomings") + vouchers in − out = expected = balance*, and
|
||
a **"This shift: ±X"** figure under the balance shows the shift's OWN contribution (expected −
|
||
opening float), separating what this operator moved from what they inherited. With no shift open it
|
||
reads as the carried-forward closing balance.
|
||
- **Today's cash activity** — every drawer-touching signed event since local midnight (cash payments +
|
||
vouchers; card never enters the till), live (15s), with day totals. Source: the existing
|
||
`/api/events` window query, filtered client-side (`event:read`).
|
||
- **Record** + **movements/review** — the 2026-07-01 flow, unchanged.
|
||
- **Closed shifts** — drawer-focused history via the existing scope-aware `/api/shifts`
|
||
(operators: own; admins: all): float → takings ± vouchers, expected drawer per shift.
|
||
|
||
Visibility note: the balance endpoint is `shift:read` on purpose — the drawer is a **single site-wide
|
||
till**, the same exposure the open shift's X-report already had, not per-operator data.
|
||
|
||
**Busy feedback on shift buttons (2026-07-05).** Opening a shift can take seconds (see the open item
|
||
below), and the buttons' only feedback was a label swap ("Opening…") — subtle enough that operators
|
||
read a slow open as a dead click. Every shift open/close button (header, /shifts, the pay-modal's
|
||
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
|
||
(`ui/Spinner.tsx`, reusable) and dims while disabled.
|
||
|
||
## Tills — one shift and one drawer per money-taking desk (built 2026-09-05)
|
||
|
||
A **till** is a physical cash drawer with its own accountability. The booth is the till that
|
||
always existed; a venue module that takes money at its own desk declares its own till in its
|
||
manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
|
||
|
||
- A shift is **opened on a till**. At most one shift open per till; tills are independent (the
|
||
booth and the wash desk run side by side, by different — or the same — operators).
|
||
- **Every money event names its till** (`payload.till`): parking `payment` and the
|
||
subscription sale = `booth` (a wash paid at the booth rides the parking payment as
|
||
`chargeLines`, so it is booth money too); `carwash_payment` at the bay = `carwash`;
|
||
`cash_in`/`cash_out` carry the drawer they moved. `shift_open`/`shift_z_report` carry theirs.
|
||
- **Absent `till` = booth.** Every event before tills existed is booth money, so the chain
|
||
re-folds identically and old Z-reports read as booth shifts. `tillOf()` in `@parking/shared`
|
||
is the one place this rule lives.
|
||
- The drawer fold, the X/Z-report window (payments **and** vouchers) and carry-forward all
|
||
filter by till: the wash operator's expected drawer is *their* float + *their* bay cash +
|
||
*their* vouchers, and the booth's never includes bay money. The counted-vs-expected moment
|
||
therefore sits with whoever holds the cash — which is the whole point (see §below).
|
||
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
|
||
carries its own shift control. The header button stays the booth's. The shift hub lists
|
||
every open shift with a till badge; the drawer hub switches tills.
|
||
- **Working a till needs that till's module permission** (added 2026-09-05 after the user
|
||
found a wash user could open the *booth's* shift): the manifest names it
|
||
(`tillPermission` — booth: `session:read`, carwash: `carwash:read`), `tillsFor()` in
|
||
`@parking/shared` resolves a role's tills, the shift/drawer routes refuse the rest with
|
||
`403 till_forbidden`, and `/api/shift/tills` + `current.tills` return only the role's
|
||
tills — so the header button, the hub's start buttons and the drawer switch never offer a
|
||
till the server would refuse. `shift:create` alone opens nothing.
|
||
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
|
||
figures are per till, the event list is not); bay slips print on the booth printer.
|
||
|
||
## Where the fraud control actually lives
|
||
|
||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||
append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager
|
||
reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's
|
||
[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely
|
||
*when* a skim is caught (after the fact, by a human), not *whether*.
|
||
|
||
> **Optional enhancement (not building now): blind cash count.** Have the operator enter the
|
||
> counted cash *before* the system reveals the expected figure, and record the variance into the
|
||
> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match
|
||
> expectation, catching a skim **at close** rather than later. Explicitly out of scope per
|
||
> 2026-06-15; documented as a clean add-on if ever wanted.
|
||
|
||
## Open
|
||
|
||
- **Drawer/shift reads fold the WHOLE chain — O(chain) growth (flagged 2026-07-05).**
|
||
`#drawerBalanceAt`, `currentOpenShift`, and `listShifts` select every ledger event and fold in JS.
|
||
Fine pre-opening; after months of operation this is a linearly growing pause on every shift open,
|
||
X-report, and drawer-balance read (the observed "opening a shift is slow"). Clean fix when it
|
||
bites: fold **from the last `shift_z_report` forward** — its `expectedDrawerMinor` is already the
|
||
signed balance snapshot at that point — instead of from genesis. Not built; the UI got busy
|
||
spinners in the meantime.
|
||
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
|
||
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
|
||
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
|
||
superseding the signed-± `cash_movement` (kept for history). As of 2026-07-01 an operator RECORDS them
|
||
freely and an admin REVIEWS after (signed `cash_review`, a flag not a reversal) — see "Drawer review".
|
||
Z-report reports the full drawer picture. See the Drawer balance section above.
|
||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||
the money. Confirm that's the intended accountability (vs. by entry).
|
||
- **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
|
||
the **opening float inherited**, **cash/card collected so far**, the **pay-ins/pay-outs**, and the
|
||
**current expected drawer balance** — without closing. `GET /api/shift/report` (`shift:read`, 204
|
||
when no shift is open) returns the SAME drawer projection the Z-report computes, factored into a
|
||
shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
|
||
endedAt, signed) can never drift. **It appends NOTHING** — it's not an accountability mark (the
|
||
Z-report at close is the signed record). UI: a "Takings so far" button on the shift control opens a
|
||
cyan X-report panel; the header still shows the live drawer *total* for the at-a-glance number.
|
||
Verified against a copy of the live DB: matches `drawerBalance()`, drawer identity holds, 0 events
|
||
appended, chain still verifies.
|
||
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
||
(relates to [[open-questions]] #1 lane topology).
|