feat(machine): record failed dispenses and per-cassette tracking

Failed dispenses (sats debited, cash not dispensed) were invisible —
transactions only recorded on 'complete'. Now records on 'dispenseError'
with status ('dispense_error'|'partial'|'complete'), error message, and
per-cassette detail.

Also fixes a bug in both HAL services where dispense results were mapped
by amounts-array index instead of cassette position, causing swapped
denomination counts when cassette order differs from request order.

Schema v3→v4: adds status/error columns to transactions, new
cassette_bills table for per-cassette provisioned/dispensed/rejected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-22 17:53:10 -04:00
commit a21865ff20
10 changed files with 287 additions and 53 deletions

View file

@ -38,6 +38,14 @@ export interface DispenseResult {
bills: { denomination: number; dispensed: number; rejected: number }[]
dispensed: boolean
error?: string
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
}
export interface HalInstance {
@ -175,26 +183,37 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
const result = await dispenser.dispense(notes)
// Update inventory
for (let i = 0; i < result.value.length; i++) {
const denom = cassetteDenominations[i]
if (denom !== undefined && inventory[denom] !== undefined) {
inventory[denom] -= result.value[i]?.dispensed ?? 0
}
}
// Build per-denomination result
const bills = amounts.map((a, i) => ({
denomination: a.denomination,
// Build per-cassette results (position-aware, matches result.value ordering)
const cassetteResults = cassetteDenominations.map((denom, i) => ({
name: `cassette${i + 1}`,
position: i,
denomination: denom,
provisioned: notes[i] ?? 0,
dispensed: result.value[i]?.dispensed ?? 0,
rejected: result.value[i]?.rejected ?? 0,
}))
// Update inventory based on what was ACTUALLY dispensed (per-cassette)
for (const c of cassetteResults) {
if (c.dispensed > 0 && inventory[c.denomination] !== undefined) {
inventory[c.denomination] -= c.dispensed
}
}
// Build per-denomination result (backward compatible, only requested denoms)
const bills = cassetteResults
.filter((c) => c.provisioned > 0)
.map((c) => ({
denomination: c.denomination,
dispensed: c.dispensed,
rejected: c.rejected,
}))
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
if (result.error) {
return { bills, dispensed: false, error: result.error.message }
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
}
// Wait for customer to take bills
@ -203,7 +222,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
console.log('[HAL] Bills removed by customer')
}
return { bills, dispensed: totalRequested === totalDispensed }
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
},
getInventory: () => ({ ...inventory }),

View file

@ -55,9 +55,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
feePercent: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => ipcRenderer.invoke('state:record-transaction', tx),
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
@ -114,9 +128,23 @@ declare global {
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
feePercent: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => Promise<void>
emptyCashbox: () => Promise<void>
// HAL hardware IPC

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '3'
const SCHEMA_VERSION = '4'
function getDbPath(): string {
const prodDir = '/var/lib/lamassu-atm'
@ -72,6 +72,8 @@ export function initDatabase(dbPath?: string): void {
fee_percent REAL NOT NULL DEFAULT 0,
exchange_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete',
error TEXT,
created_at INTEGER NOT NULL
);
@ -81,6 +83,17 @@ export function initDatabase(dbPath?: string): void {
denomination INTEGER NOT NULL,
count INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cassette_bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid TEXT NOT NULL REFERENCES transactions(txid),
name TEXT NOT NULL,
position INTEGER NOT NULL,
denomination INTEGER NOT NULL,
provisioned INTEGER NOT NULL DEFAULT 0,
dispensed INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0
);
`)
// Seed meta + cashbox if first run, or run migrations
@ -108,6 +121,28 @@ export function initDatabase(dbPath?: string): void {
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version')
console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)')
existing.value = '3'
}
if (existing && existing.value === '3') {
// Migration v3 → v4: add status/error to transactions, cassette_bills table
db.exec(`
ALTER TABLE transactions ADD COLUMN status TEXT NOT NULL DEFAULT 'complete';
ALTER TABLE transactions ADD COLUMN error TEXT;
CREATE TABLE IF NOT EXISTS cassette_bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
txid TEXT NOT NULL REFERENCES transactions(txid),
name TEXT NOT NULL,
position INTEGER NOT NULL,
denomination INTEGER NOT NULL,
provisioned INTEGER NOT NULL DEFAULT 0,
dispensed INTEGER NOT NULL DEFAULT 0,
rejected INTEGER NOT NULL DEFAULT 0
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
}
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
@ -235,6 +270,7 @@ export function emptyCashbox(): void {
interface TransactionInput {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
@ -242,6 +278,15 @@ interface TransactionInput {
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}
/**
@ -251,11 +296,14 @@ export function recordTransaction(tx: TransactionInput): void {
if (!db) throw new Error('Database not initialized')
const insertTx = db.prepare(
'INSERT INTO transactions (txid, type, fiat_cents, sats, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO transactions (txid, type, status, error, fiat_cents, sats, fee_sats, fee_percent, exchange_rate, currency, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
)
const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
)
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 = ?'
)
@ -267,6 +315,8 @@ export function recordTransaction(tx: TransactionInput): void {
insertTx.run(
t.txid,
t.type,
t.status,
t.error ?? null,
t.fiatCents,
t.sats,
t.feeSats,
@ -278,10 +328,36 @@ export function recordTransaction(tx: TransactionInput): void {
for (const bill of t.bills) {
insertBill.run(t.txid, bill.denomination, bill.count)
}
if (t.type === 'cash_out') {
// Dispensed bills: decrement cassettes
updateCassette.run(-bill.count, bill.denomination)
// Insert per-cassette detail when available
if (t.cassettes) {
for (const c of t.cassettes) {
insertCassetteBill.run(
t.txid,
c.name,
c.position,
c.denomination,
c.provisioned,
c.dispensed,
c.rejected
)
}
}
if (t.type === 'cash_out') {
// Decrement cassettes by ACTUALLY dispensed count (not requested)
if (t.cassettes) {
for (const c of t.cassettes) {
if (c.dispensed > 0) {
updateCassette.run(-c.dispensed, c.denomination)
}
}
} else {
// Fallback: use bill counts (backward compat for mocks without cassette data)
for (const bill of t.bills) {
updateCassette.run(-bill.count, bill.denomination)
}
}
}
@ -293,7 +369,7 @@ export function recordTransaction(tx: TransactionInput): void {
})
run(tx)
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type)
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
}
/**