Files
parking_solution/wiki/concepts/shift.md
T
julian 2835f78635 feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)
Replace the single signed-± cash_movement with two distinct financial
documents — the direction is the event TYPE, not the sign of an amount:

  cash_in  = Mandat Arkëtimi (receipt / pay-IN,  +)  voucher AR-NNNN
  cash_out = Mandat Pagese  (disbursement / pay-OUT, −)  voucher PA-NNNN

Each carries a positive magnitude, voucher number, reason, the operator who
raised it and the admin who authorized it, and prints an Albanian slip.

Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED:
any shift:create holder raises the voucher, but POST /api/cash-voucher only
commits when authorizedBy is a real admin (shift:cash) re-entering their
password (verified server-side). Keeps the float control while letting the
operator do the booth paperwork.

Legacy cash_movement events are kept — they still verify and still fold into
the drawer (signed-±); the append-only chain is never rewritten. The drawer
fold and the Z-report window now sum all three types.

Verified against a copy of the live DB with the real signing modules:
cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:18:26 +02:00

215 lines
14 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-06-20
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.
> **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 raised), authorizedBy (admin who
signed off), voucherNo }`. Each prints a **slip** (Albanian, like every operator-facing paper).
- **Authorization changed: operator-RAISED, admin-AUTHORIZED.** Previously admin-only. Now any holder
of `shift:create` (operator-grade) may *raise* a voucher, but the route only commits it if
`authorizedBy` is a real **admin** (`shift:cash`) who **re-enters their password**. This keeps the
float control — an operator can't move the float alone — while letting them do the paperwork at the
booth. (`POST /api/cash-voucher`, guarded `shift:create` + server-side authorizer password+grade check.)
- **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** (a drawer voucher is the
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
```
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`
```
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.
## 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):** opening float
auto-inherits the prior shift's expected drawer; drawer movements are now the **`cash_in` /
`cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type, operator-raised
& admin-authorized), superseding the signed-± `cash_movement` (kept for history). 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 — REQUESTED 2026-06-20, not yet built.** The operator wants to see,
on demand during the shift, the **opening float inherited**, **cash collected so far**, the
pay-ins/pay-outs, and the **current expected drawer balance** — without closing. It's the same
drawer projection the Z-report computes, just read-only and mid-shift. (The header already shows the
live drawer *total*; this is the full breakdown.) Deferred behind the voucher re-model done the same
day; build next if wanted.
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
(relates to [[open-questions]] #1 lane topology).