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

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>
This commit is contained in:
Padreug 2026-07-04 00:28:24 +02:00
commit 79e1823cc5
2 changed files with 195 additions and 6 deletions

View file

@ -982,8 +982,11 @@ export function recordTransaction(tx: TransactionInput): void {
const insertCassetteBill = db.prepare(
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
)
const updateCassette = db.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
const updateCassetteByPosition = db.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?'
)
const selectBaysByDenom = db.prepare(
'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position'
)
const updateCashboxStmt = db.prepare(
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
@ -1024,17 +1027,33 @@ export function recordTransaction(tx: TransactionInput): void {
}
if (t.type === 'cash_out') {
// Decrement cassettes by ACTUALLY dispensed count (not requested)
// Decrement cassettes by ACTUALLY dispensed count (not requested).
// Position is the addressable unit (v9): duplicate denominations
// across bays are legal, so a denomination-keyed UPDATE would
// decrement every matching bay.
if (t.cassettes) {
for (const c of t.cassettes) {
if (c.dispensed > 0) {
updateCassette.run(-c.dispensed, c.denomination)
updateCassetteByPosition.run(-c.dispensed, c.position)
}
}
} else {
// Fallback: use bill counts (backward compat for mocks without cassette data)
// Fallback: per-denomination bill counts (mocks without per-bay
// results). Drain matching bays greedily in position order —
// the dispenser's own fill order.
for (const bill of t.bills) {
updateCassette.run(-bill.count, bill.denomination)
let remaining = bill.count
const bays = selectBaysByDenom.all(bill.denomination) as {
position: number
count: number
}[]
for (const bay of bays) {
if (remaining <= 0) break
const take = Math.min(remaining, bay.count)
if (take <= 0) continue
updateCassetteByPosition.run(-take, bay.position)
remaining -= take
}
}
}
}