diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts new file mode 100644 index 0000000..fbb9a87 --- /dev/null +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -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 { + const out: Record = {} + 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 }) + }) +}) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index a274565..cfbfd71 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -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 + } } } }