Let the operator see, on demand during an open shift, the opening float inherited, cash/card collected so far, 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 that math into a shared ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now, read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't drift. The X-report appends NOTHING — it's a snapshot, not an accountability mark; the Z-report at close remains the signed record. UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header still shows the live drawer total for the at-a-glance figure. Verified against a copy of the live DB: X figures match drawerBalance(), the drawer identity holds, zero events appended, chain still verifies. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
14 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-06-20 | 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()checkscurrentOpenShift()(the single site-wide open shift = most recent shift event on the whole chain is ashift_open), and throwsShiftAlreadyOpenErrorcarryingheldByso 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/reopenrun arequireShiftpreHandler 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
- Determine the shift's payment set: the signed
paymentevents (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 signedpaymentwithsubscriptionSale: true, amountpriceMinor × 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".) - Sum by tender:
cashTotal, andcardTotalfrom the POS/terminal if a POS is configured (the card line is omitted when there's no terminal). - Append a signed
shift_z_reportevent (type already inpackages/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. - 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 format19 Qershor 2026 10:48:25, shared viaformatStampSqfrom 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_reportat close. The operator is the logged-in user, carried in the eventidentity.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 forclose()(you close your own shift). - Close sums
paymentevents in[startedAt, endedAt]by tender (cash vs. card, by payment time), appends the signedshift_z_report(totals + counts + window), then prints via the new genericPrinterDevice.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:falseis 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). UIShiftControlin 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.amountMinoris a positive magnitude. Voucher no.AR-NNNN.cash_out(Mandat Pagese — a disbursement / pay-OUT): cash leaves the drawer.amountMinorpositive; 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 ifauthorizedByis 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, guardedshift:create+ server-side authorizer password+grade check.) - Legacy
cash_movementstays 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
paymentevents 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_outvoucher 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 — 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 sharedShiftService.#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: matchesdrawerBalance(), 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).