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
+37 -10
View File
@@ -21,7 +21,8 @@ export const RESOURCES = [
"subscription", // the subscription registry
"site", // site_config + device setup/assign
"device", // device status / printers / snapshots / catalog
"shift", // open/close own shift; move the drawer float
"shift", // open/close own shift
"drawer", // record cash receipts/disbursements (operator); review them (admin)
"payment", // take payment, quote, voucher/receipt, exit, reopen
"session", // active sessions, lookup
"event", // the signed ledger feed + void
@@ -33,9 +34,11 @@ export const RESOURCES = [
export type Resource = (typeof RESOURCES)[number];
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
* delete), `cash` (move the drawer float — admin-grade shift action), and `plan`
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
* delete), `cash` (admin-grade shift scope — see all operators' shifts), `plan` (compose
* the subscription plan catalog — admin-grade; selling stays `create`), and `review`
* (admin authorizes/denies a drawer movement an operator recorded — a flag, not a
* reversal; see wiki/concepts/shift.md). */
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan" | "review";
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
export type Permission = `${Resource}:${Action}`;
@@ -53,6 +56,11 @@ export const PERMISSIONS: readonly Permission[] = [
"site:read", "site:update",
"device:read",
"shift:read", "shift:create", "shift:cash",
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
// admin sign-off at creation; admin-revocable per role) and review (admin AUTHORIZES or
// DENIES a recorded movement after the fact — a flag, never a cash reversal). A denial is
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
"drawer:create", "drawer:review",
"payment:read", "payment:create",
"session:read",
"event:read", "event:void",
@@ -241,11 +249,19 @@ export type LedgerEventType =
// financial documents — the direction is the TYPE, not the sign of an amount):
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised
// by), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised by),
// voucherNo }. OPERATOR-RECORDED (freely; no admin sign-off at creation — 2026-07-01).
// Folds into the drawer balance. Reviewed after the fact via cash_review (below).
// See wiki/concepts/shift.md.
| "cash_in"
| "cash_out"
// Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Payload: { refId (the
// reviewed movement's event id), decision: "authorize"|"deny", reviewedBy, note?,
// currency? }. A FLAG only — it NEVER moves cash: a denial is a judgment about the
// operator (settled outside the app), so it does NOT reverse the movement and does NOT
// touch the drawer balance. Append-only, signed, so the decision is itself auditable.
// See wiki/concepts/shift.md.
| "cash_review"
| "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
@@ -292,12 +308,23 @@ export interface LedgerPayload {
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
* pricing reprices identically at exit. Absent on legacy entries (= default). */
readonly category?: string;
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
* operator in `operator` raised it). Operator-raised / admin-authorized. */
readonly authorizedBy?: string;
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
readonly voucherNo?: string;
/** LEGACY cash_in / cash_out (pre-2026-07-01): the admin who AUTHORIZED the movement
* at creation. The current flow records movements freely and reviews them AFTER via a
* cash_review event, so new movements do NOT carry this. Kept so historical events
* still verify + display. See wiki/concepts/shift.md. */
readonly authorizedBy?: string;
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
readonly refId?: string;
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
* neither value moves cash or touches the drawer balance. */
readonly decision?: "authorize" | "deny";
/** cash_review: the admin (username) who made the decision. */
readonly reviewedBy?: string;
/** cash_review: optional free-text admin note (e.g. why a movement was denied). */
readonly note?: string;
/** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window,
* so the minutes actually parked out-of-window are charged at the transient tariff and
* collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long