fix(machine): decrement cassettes by position, not denomination, on cash-out #75
2 changed files with 195 additions and 6 deletions
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>
commit
79e1823cc5
170
apps/machine/electron/__tests__/state-store-transactions.test.ts
Normal file
170
apps/machine/electron/__tests__/state-store-transactions.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Tests for recordTransaction inventory accounting.
|
||||
*
|
||||
* Regression coverage for the position-vs-denomination decrement bug:
|
||||
* position is the cassettes PK (v9) and duplicate denominations across
|
||||
* bays are legal, so cash-out decrements MUST address bays by position.
|
||||
* A denomination-keyed UPDATE would drain every matching bay at once.
|
||||
*
|
||||
* Uses an in-memory SQLite database — fresh per test, no on-disk
|
||||
* artifacts, no parallel-test interference.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
closeDatabase,
|
||||
getCashbox,
|
||||
initDatabase,
|
||||
loadCassettes,
|
||||
recordTransaction,
|
||||
setCassettes,
|
||||
} from '../state-store.js'
|
||||
|
||||
const TX_BASE = {
|
||||
fiatCents: 4000,
|
||||
sats: 100_000,
|
||||
feeSats: 5_000,
|
||||
feeFraction: 0.05,
|
||||
exchangeRate: 2500,
|
||||
currency: 'USD',
|
||||
}
|
||||
|
||||
/** Two $20 bays plus one $50 bay — the duplicate-denomination layout. */
|
||||
function seedDuplicateDenomBays() {
|
||||
setCassettes([
|
||||
{ position: 1, denomination: 20, count: 50 },
|
||||
{ position: 2, denomination: 20, count: 50 },
|
||||
{ position: 3, denomination: 50, count: 30 },
|
||||
])
|
||||
}
|
||||
|
||||
function countsByPosition(): Record<number, number> {
|
||||
const out: Record<number, number> = {}
|
||||
for (const row of loadCassettes()) out[row.position] = row.count
|
||||
return out
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initDatabase(':memory:')
|
||||
seedDuplicateDenomBays()
|
||||
})
|
||||
afterEach(() => {
|
||||
closeDatabase()
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction cash_out inventory', () => {
|
||||
it('decrements only the bay that actually dispensed (duplicate denominations)', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-single-bay',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 3 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 3,
|
||||
dispensed: 3,
|
||||
rejected: 0,
|
||||
},
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 0,
|
||||
dispensed: 0,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 47, 2: 50, 3: 30 })
|
||||
})
|
||||
|
||||
it('decrements each bay by its own dispensed count on a split dispense', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-split-bays',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 60 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette1',
|
||||
position: 1,
|
||||
denomination: 20,
|
||||
provisioned: 50,
|
||||
dispensed: 50,
|
||||
rejected: 0,
|
||||
},
|
||||
{
|
||||
name: 'cassette2',
|
||||
position: 2,
|
||||
denomination: 20,
|
||||
provisioned: 10,
|
||||
dispensed: 10,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
||||
})
|
||||
|
||||
it('fallback without cassette results drains matching bays greedily by position', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-fallback',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 20, count: 60 }],
|
||||
})
|
||||
|
||||
// Bay 1 (50 bills) drains fully, bay 2 covers the remaining 10.
|
||||
expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 })
|
||||
})
|
||||
|
||||
it('never drives a bay count below zero', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-overdispense',
|
||||
type: 'cash_out',
|
||||
status: 'complete',
|
||||
bills: [{ denomination: 50, count: 35 }],
|
||||
cassettes: [
|
||||
{
|
||||
name: 'cassette3',
|
||||
position: 3,
|
||||
denomination: 50,
|
||||
provisioned: 35,
|
||||
dispensed: 35,
|
||||
rejected: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-store: recordTransaction cash_in cashbox', () => {
|
||||
it('adds inserted bills to the cashbox and leaves cassettes untouched', () => {
|
||||
recordTransaction({
|
||||
...TX_BASE,
|
||||
txid: 'tx-cash-in',
|
||||
type: 'cash_in',
|
||||
status: 'complete',
|
||||
bills: [
|
||||
{ denomination: 20, count: 2 },
|
||||
{ denomination: 50, count: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const cashbox = getCashbox()
|
||||
expect(cashbox.totalBills).toBe(3)
|
||||
expect(cashbox.totalFiatCents).toBe(TX_BASE.fiatCents)
|
||||
expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue