--- 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=`); 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).