Files
parking_solution/wiki/concepts/shift.md
T
julian 114a32e6f2 feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.

- New `drawer` resource: drawer:create (operator records; admin-revocable per
  role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
  default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
  A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
  touches the drawer balance (the correction is settled outside the app). This
  is what keeps a late review from leaking into the next operator's inherited
  drawer — a denial that lands after the reviewed shift closed moves no cash.
  Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
  op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
  (operator: record + own; admin: review queue + all). routes/drawer.ts lifted
  from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
  for its other job = admin-sees-all-shifts). New DrawerManager.tsx.

Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
  movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
  Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
  on-site), matching the card-tender gate.

shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 11:17:20 +02:00

277 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-07-01
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)
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`.
## 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 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).