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).