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:
+52
-15
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user