fix(machine): decrement cassettes by position, not denomination, on cash-out #75

Merged
padreug merged 1 commit from fix/cassette-decrement-by-position into dev 2026-07-03 22:31:48 +00:00
Owner

Problem

recordTransaction() in apps/machine/electron/state-store.ts decremented cassette inventory with

UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?

but the v9 migration made position the PK precisely so duplicate denominations across bays are legal (applyOperatorCassettesConfig documents this explicitly, and the HAL dispense path builds per-position results with position as the authoritative field). On any machine with two bays of the same denomination — a dual-$20 layout, tejo/batm3 multi-bay configs — a single dispense decremented every matching bay row, silently corrupting:

  • persisted inventory (survives reboots, so it compounds),
  • the cassette-state publish to spirekeeper,
  • "out of money" / insufficient-inventory gating.

The correct helper updateCassetteCountByPosition() already existed but had no callers — an unfinished refactor from the v9 cutover.

Fix

  • Cassettes branch (production path): decrement by c.position.
  • Fallback branch (mock-only — HAL always returns per-bay results): drain matching bays greedily in position order, mirroring the dispenser's own fill order, instead of a denomination-keyed blanket UPDATE.

Tests

New state-store-transactions.test.ts (in-memory DB, same pattern as the fees/bunker tests) with a duplicate-denomination layout (2× $20 bays + 1× $50):

  • single-bay dispense touches only that bay
  • split dispense decrements each bay by its own dispensed count
  • fallback drains greedily by position
  • MAX(0, …) floor holds
  • cash-in updates cashbox, leaves cassettes untouched

3 of the 5 tests fail against the old code; all 43 machine-app tests + vue-tsc pass with the fix.

Out of scope (noted during review)

manual_dispense transactions never decrement DB cassette counts at all (the decrement branch is cash_out-only) while HAL decrements its in-memory bays — DB inventory overstates after an operator remediation dispense until cassettes are re-set. Separate issue to follow.

🤖 Generated with Claude Code

## Problem `recordTransaction()` in `apps/machine/electron/state-store.ts` decremented cassette inventory with ```sql UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ? ``` but the v9 migration made `position` the PK **precisely so duplicate denominations across bays are legal** (`applyOperatorCassettesConfig` documents this explicitly, and the HAL dispense path builds per-position results with position as the authoritative field). On any machine with two bays of the same denomination — a dual-$20 layout, tejo/batm3 multi-bay configs — a single dispense decremented **every** matching bay row, silently corrupting: - persisted inventory (survives reboots, so it compounds), - the cassette-state publish to spirekeeper, - "out of money" / insufficient-inventory gating. The correct helper `updateCassetteCountByPosition()` already existed but had no callers — an unfinished refactor from the v9 cutover. ## Fix - **Cassettes branch** (production path): decrement by `c.position`. - **Fallback branch** (mock-only — HAL always returns per-bay results): drain matching bays greedily in position order, mirroring the dispenser's own fill order, instead of a denomination-keyed blanket UPDATE. ## Tests New `state-store-transactions.test.ts` (in-memory DB, same pattern as the fees/bunker tests) with a duplicate-denomination layout (2× $20 bays + 1× $50): - single-bay dispense touches only that bay - split dispense decrements each bay by its own dispensed count - fallback drains greedily by position - `MAX(0, …)` floor holds - cash-in updates cashbox, leaves cassettes untouched 3 of the 5 tests fail against the old code; all 43 machine-app tests + `vue-tsc` pass with the fix. ## Out of scope (noted during review) `manual_dispense` transactions never decrement DB cassette counts at all (the decrement branch is `cash_out`-only) while HAL decrements its in-memory bays — DB inventory overstates after an operator remediation dispense until cassettes are re-set. Separate issue to follow. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
recordTransaction() updated cassette counts with WHERE denomination = ?,
but the v9 migration made position the PK precisely so duplicate
denominations across bays are legal (and the HAL dispense path already
returns authoritative per-position results). On any machine with two
bays of the same denomination, a single dispense drained every matching
bay row — silently corrupting inventory, the operator cassette-state
publish, and out-of-money gating.

- cassettes branch: decrement by c.position
- mock-only fallback (no per-bay results): drain matching bays greedily
  in position order, mirroring the dispenser's own fill order
- regression tests with a duplicate-denomination layout (3 of 5 fail
  against the old code)

Found during the dev-branch architecture review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
padreug deleted branch fix/cassette-decrement-by-position 2026-07-03 22:31:48 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!75
No description provided.