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

19 KiB
Raw Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
shifts
anti-fraud
2026-07-01 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 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 (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): 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).