feat(shift): cash drawer balance carried across shifts + admin cash movements

New signed cash_movement event (admin-only): load/remove drawer float, signed +
attributed. ShiftService folds cash payments + movements by time into a drawer
balance; shift open auto-inherits the prior shift's expected closing drawer as its
opening float; the Z-report reports opening/taken/added/removed/expected (= next
shift's opening float). Card payments excluded (settle to bank). Routes: POST
/api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live
drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
This commit is contained in:
2026-06-18 11:05:36 +02:00
parent eb3dc18e67
commit 50a3095ef3
5 changed files with 327 additions and 24 deletions
+51
View File
@@ -70,6 +70,54 @@ no variance gate, no manager override.
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:**
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
are both `cash_movement` events.
- 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 `cash_movement` is the
admin's, not the shift operator's, so it can't key off `identity`):
```
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
+ Σ cash_movement amounts up to `at`
```
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 | admin `cash_movement` +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 |
| admin `cash_movement` −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.
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
@@ -86,6 +134,9 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
## Open
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
expected drawer; admin-only `cash_movement` events; 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).