Closes the three known follow-ups of the Tills decision (venue-modules.md): - Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events by payload till, other events by their owning module's till, everything else booth), applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today" panel and the booth feed (history + live pushes). The events route admits a role that holds a module feed permission without event:read and returns only that module's event types — the live-socket rule. - Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by module; the ticket bucket excludes them (Bileta = parking only); printed "Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:". - Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the one reading of the role field (the entry/booth loaders treated any non-booth role as an entry dispenser). Footer label "at wash desk". Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new requireAnyPermission) — the Wash operator job could not load the desk's category and service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules, log) updated. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
26 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
2026-09-05 | 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)
Superseded 2026-09-05 — now PER TILL (built). With money-taking venue modules (Car Wash at the bay), "site-wide" became per till: one open shift and one drawer per till (
booth,carwash, …), each with its own operator, float, vouchers and Z-report; every money event names its till. See §"Tills" below and venue-modules §"Tills". Everything in this section stays true within a till.
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.
Takings split by SOURCE + confirm-before-close (2026-06-21). Two related changes:
The report now splits takings into Tickets (transient) vs Subscriptions (monthly
subscriptionSale+ a subscriber's out-of-windowsubscriptionWindowCharge), so the operator sees subscriber money apart from ticket money. The buckets are derived from the signed payment payload flags and always reconcile tocash + card(a payment with neither flag is a ticket). Computed once in#summariseWindow, carried on the signedshift_z_reportpayload (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 —Abonimeis 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 soopening + cash-taken = expected drawerreads explicitly.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 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 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_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 (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 acash_in/cash_outfreely — 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 ispendinguntil an admin authorizes or denies it. The decision is a new signedcash_reviewevent{ 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) andPOST /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
/shiftsto its own top-level/drawerroute (operator: record- own movements; admin: the review queue + all movements).
/shiftsis now just open/close + Z-report. Server:routes/drawer.ts(lifted out ofroutes/shift.ts); UI:DrawerManager.tsx.
- own movements; admin: the review queue + all movements).
Drawer hub — the page answers "what's in the till and why" (2026-07-05)
Operator feedback: /drawer was too simple — record + review only, with no current balance, no
sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub of five
panels, all reads over data that already existed:
- Drawer now — the running balance (new
GET /api/drawer/balance,shift:read; a passthrough to the service's existingdrawerBalance(), which was never exposed). While a shift is open, the X-report breakdown sits beside it so the number is always explainable: opening float + cash takings (with payment count — the "current shift incomings") + vouchers in − out = expected = balance, and a "This shift: ±X" figure under the balance shows the shift's OWN contribution (expected − opening float), separating what this operator moved from what they inherited. With no shift open it reads as the carried-forward closing balance. - Today's cash activity — every drawer-touching signed event since local midnight (cash payments +
vouchers; card never enters the till), live (15s), with day totals. Source: the existing
/api/eventswindow query, filtered client-side (event:read). - Record + movements/review — the 2026-07-01 flow, unchanged.
- Closed shifts — drawer-focused history via the existing scope-aware
/api/shifts(operators: own; admins: all): float → takings ± vouchers, expected drawer per shift.
Visibility note: the balance endpoint is shift:read on purpose — the drawer is a single site-wide
till, the same exposure the open shift's X-report already had, not per-operator data.
Busy feedback on shift buttons (2026-07-05). Opening a shift can take seconds (see the open item
below), and the buttons' only feedback was a label swap ("Opening…") — subtle enough that operators
read a slow open as a dead click. Every shift open/close button (header, /shifts, the pay-modal's
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
(ui/Spinner.tsx, reusable) and dims while disabled.
Tills — one shift and one drawer per money-taking desk (built 2026-09-05)
A till is a physical cash drawer with its own accountability. The booth is the till that
always existed; a venue module that takes money at its own desk declares its own till in its
manifest (Car Wash → carwash; a future Bar → bar). Rules:
- A shift is opened on a till. At most one shift open per till; tills are independent (the booth and the wash desk run side by side, by different — or the same — operators).
- Every money event names its till (
payload.till): parkingpaymentand the subscription sale =booth(a wash paid at the booth rides the parking payment aschargeLines, so it is booth money too);carwash_paymentat the bay =carwash;cash_in/cash_outcarry the drawer they moved.shift_open/shift_z_reportcarry theirs. - Absent
till= booth. Every event before tills existed is booth money, so the chain re-folds identically and old Z-reports read as booth shifts.tillOf()in@parking/sharedis the one place this rule lives. - The drawer fold, the X/Z-report window (payments and vouchers) and carry-forward all filter by till: the wash operator's expected drawer is their float + their bay cash + their vouchers, and the booth's never includes bay money. The counted-vs-expected moment therefore sits with whoever holds the cash — which is the whole point (see §below).
- "Take money at the bay" requires the carwash shift, not the booth's; the wash desk carries its own shift control. The header button stays the booth's. The shift hub lists every open shift with a till badge; the drawer hub switches tills.
- Each desk's money is guarded by that desk's own permissions (2026-09-05, after the
user found a wash user could open the booth's shift; design on venue-modules
§"Permissions matrix"). The manifest declares
tillGuards { read, shift, cash }: booth =shift:read/shift:create/drawer:create(unchanged), carwash =carwash:read/carwash:cash/carwash:cash. The shift + drawer routes resolve the guard FROM THE TILL (requireTill(kind);403 till_forbidden),/api/shift/tillslists the tills a role may read with acanWorkflag, and history / movements without a till filter return the union of the role's readable tills. So a wash role holds noshift:*at all and cannot touch the booth by construction; the header button, the hub's start buttons and the drawer switch never offer a till the server would refuse. (A first cut that borrowedsession:readas "works the booth till" lived for a few hours and is gone.) - The activity log is per till too (2026-09-06).
tillOfEvent(type, payload)in@parking/sharedextends the money rule to every event: a money event names its till, any other event belongs to the till of the module that owns its type (acarwash_orderis wash-desk activity though no money moved), everything else — entries, exits, barrier commands, pre-till events — is the booth's./api/events?till=applies the same rule in SQL (so the page limit applies after the filter); the hub's shift log, the Drawer "today" panel and the booth feed (history and live pushes) pass their till. The events route also admits a role withoutevent:readthat holds a module's feed permission, and then returns only that module's event types — the live-socket rule, so a wash operator's hub shows the wash shift's log. - The booth Z-report breaks module money out (2026-09-06).
chargesByModuleMinor({ carwash: <minor> }, only when any was taken) sums thechargeLineson the till's payments by owning module; the ticket bucket EXCLUDES it, soBiletais parking money only and ticket + subscriptions + Σcharges = cash + card. Printed asLavazh (në biletë): Xon the booth slip; the wash till's own slip prints its takings underLavazh:(it sells no tickets or subscriptions). Signed onshift_z_report; older reports read back as{}. - The wash till prints on its own printer (2026-09-06). Printer role
wash-desk; the wash till's Z-report and vouchers go there, falling back to the booth printer — see printer-roles-failover.
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/shift reads fold the WHOLE chain — O(chain) growth (flagged 2026-07-05).
#drawerBalanceAt,currentOpenShift, andlistShiftsselect every ledger event and fold in JS. Fine pre-opening; after months of operation this is a linearly growing pause on every shift open, X-report, and drawer-balance read (the observed "opening a shift is slow"). Clean fix when it bites: fold from the lastshift_z_reportforward — itsexpectedDrawerMinoris already the signed balance snapshot at that point — instead of from genesis. Not built; the UI got busy spinners in the meantime. - 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_outvoucher 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 (signedcash_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 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).