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:
parent
3ab03d05ac
commit
a21865ff20
10 changed files with 287 additions and 53 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue