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:
parent
86b35b5415
commit
79e1823cc5
2 changed files with 195 additions and 6 deletions
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 })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue