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
This commit is contained in:
2026-07-01 11:17:20 +02:00
parent 018328a877
commit 114a32e6f2
18 changed files with 879 additions and 206 deletions
+52 -15
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-06-20
updated: 2026-07-01
status: open
---
@@ -152,21 +152,20 @@ lives in the **event type**, not the sign of an amount:
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.)
- 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** (a drawer voucher is the
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
**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`
@@ -175,6 +174,9 @@ expectedDrawer(at) = Σ cash payments (tender=cash) 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.
@@ -201,6 +203,40 @@ Card payments are excluded from the drawer (they settle to the bank, not the til
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
@@ -217,11 +253,12 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
## 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.
- **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).
+1 -1
View File
@@ -88,7 +88,7 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01).
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
+20
View File
@@ -2088,3 +2088,23 @@ modal shows Total + "Pay + open barrier" with NO tender/cash/card row. Re-enable
a bank-certified P2PE terminal is provisioned (PCI scope stays out of the app — the terminal captures
card data, not the app). New page concepts/card-payments.md documents current state + future-POS
device requirements + re-enable path; linked from index, parking-session, open-questions #3.
## [2026-07-01] feat | Drawer redesign — operator records freely, admin reviews after; moved to /drawer
Reworked drawer cash movements from synchronous admin-authorization-at-creation (operator typed an
admin's password inline at the booth for every receipt/disbursement) to operator-records → admin-
reviews-after. An operator with drawer:create RECORDS a cash_in/cash_out freely; it counts in the
drawer immediately. An admin with drawer:review AUTHORIZES/DENIES it after via a new signed cash_review
event { refId, decision, reviewedBy, note? }. THE LOAD-BEARING CHOICE (settled with user): a denial is
a FLAG, not a reversal — it never appends reversing cash and never touches the drawer balance (the
correction is the admin's/accountant's job outside the app; we are NOT building accounting). This kills
the cross-shift-leak problem the user raised: a denial that lands after the reviewed shift closed can't
pollute the next operator's inherited drawer, because it moves no cash. New `drawer` resource +
drawer:create (per-role revocable) / drawer:review permissions; migration 0018 grants operator
drawer:create. Feature moved OFF the polluted /shifts route to a top-level /drawer (operator: record +
own; admin: review queue + all). New routes/drawer.ts (lifted from routes/shift.ts, retired the
authorizer-password gate; kept shift:cash for its other job = admin-sees-all-shifts scope),
DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/test green (225 server
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
authorizedBy). Recorded in shift.md "Drawer review".