Merge pull request 'fix(machine): decrement cassettes by position, not denomination, on cash-out' (#75) from fix/cassette-decrement-by-position into dev
Reviewed-on: #75
This commit is contained in:
commit
3cee0c5301
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -982,8 +982,11 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
const insertCassetteBill = db.prepare(
|
const insertCassetteBill = db.prepare(
|
||||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
const updateCassette = db.prepare(
|
const updateCassetteByPosition = db.prepare(
|
||||||
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
|
'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(
|
const updateCashboxStmt = db.prepare(
|
||||||
'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1'
|
'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') {
|
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) {
|
if (t.cassettes) {
|
||||||
for (const c of t.cassettes) {
|
for (const c of t.cassettes) {
|
||||||
if (c.dispensed > 0) {
|
if (c.dispensed > 0) {
|
||||||
updateCassette.run(-c.dispensed, c.denomination)
|
updateCassetteByPosition.run(-c.dispensed, c.position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
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