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

@ -150,7 +150,7 @@ alice-pay <invoice>
For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides:
| Service | Description |
| ----------------- | ------------------------------------- |
| ------------------ | -------------------------------------------------------------- |
| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) |
| 3 CLN nodes | Core Lightning with REST/gRPC |
| 1 Eclair node | ACINQ Eclair implementation |
@ -289,13 +289,26 @@ Drivers are ported from `lamassu-machine/lib/`:
| ---------- | ------------------------------------------------------------ |
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
| Dispensers | puloon, f56, genmega, hcm2, gsr50 |
| Recyclers | MEI SCR (planned — hardware exists in BATM3, no driver yet) |
| Printers | nippon, zebra, genmega |
### BATM3 Hardware Topology
The GeneralBytes BATM3 has two separate cash-handling units:
| Unit | Hardware | Role | Cassettes |
| ----------------- | ---------- | ---------------------------------- | ------------------------------------ |
| MEI Cash Recycler | MEI SCR | Cash-in (accept bills) + recycling | 2 recycler (60 each) + cashbox (600) |
| F56 Dispenser | Puloon F56 | Cash-out (dispense only) | 2 dispenser cassettes |
**Current status**: MEI used as validator-only (`cashflowSc` driver), F56 for dispensing.
The MEI's recycler cassettes are untapped — accepted bills go to cashbox, not recycled.
When porting:
1. Read JS driver thoroughly
2. Document protocol from JS code
3. Implement Rust version
3. Implement TypeScript version
4. Test against same hardware
5. Use `/hal-check port <driver>` to validate

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-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
const bills = amounts.map((a, i) => ({
denomination: a.denomination,
dispensed: result.value[i]?.dispensed ?? 0,
rejected: result.value[i]?.rejected ?? 0,
// 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,12 +328,38 @@ export function recordTransaction(tx: TransactionInput): void {
for (const bill of t.bills) {
insertBill.run(t.txid, bill.denomination, bill.count)
}
// 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') {
// Dispensed bills: decrement cassettes
// 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)
}
}
}
if (t.type === 'cash_in') {
// Bills inserted by customer go into cashbox
@ -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})`)
}
/**

View file

@ -149,26 +149,37 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
const result = await dispenser.dispense(notes)
// Update inventory based on what was ACTUALLY dispensed
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-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 && c.denomination in inventory) {
inventory[c.denomination] = (inventory[c.denomination] ?? 0) - c.dispensed
}
}
// Build per-denomination result
const bills = amounts.map((a, i) => ({
denomination: a.denomination,
dispensed: result.value[i]?.dispensed ?? 0,
rejected: result.value[i]?.rejected ?? 0,
// 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 (only if bills were dispensed)
@ -177,7 +188,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
console.log('[HAL] Bills removed by customer')
}
return { bills, dispensed: totalRequested === totalDispensed }
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
},
getInventory: async () => {

View file

@ -19,6 +19,18 @@ import type { TransactionRecord } from '@/types/state'
// Check if we're running in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
/**
* Compute fee in sats from context.
* Cash-in: customer gets net (gross - fee). Cash-out: customer pays gross + fee.
*/
function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
const grossSats = ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const feeSats = isCashIn
? grossSats - ctx.satsAmount // cash-in: customer gets net
: ctx.satsAmount - grossSats // cash-out: customer pays gross + fee
return Math.max(0, feeSats)
}
/**
* Load inventory from SQLite via IPC (Electron only).
* Returns empty object in browser dev mode.
@ -261,31 +273,67 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CASH_DISPENSED' })
}
if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) {
// Record failed cash-out dispenses (sats debited but cash not dispensed)
if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') {
const ctx = newSnapshot.context
if (ctx.txid) {
const isCashInTx = typeof state === 'object' && 'cashIn' in state
const bills: { denomination: number; count: number }[] = isCashInTx
? buildBillsFromInserted(ctx.billsInserted)
: ctx.dispenseAmounts
const dr = ctx.dispenseResult
// Compute fee in sats from context
const grossSats =
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0
const feeSats = isCashInTx
? grossSats - ctx.satsAmount // cash-in: customer gets net (gross - fee)
: ctx.satsAmount - grossSats // cash-out: customer pays gross + fee
// Determine status from dispense result (if available)
let status: 'dispense_error' | 'partial' = 'dispense_error'
let bills: { denomination: number; count: number }[] = []
if (dr) {
const totalDispensed = dr.bills.reduce((s, b) => s + b.dispensed, 0)
if (totalDispensed > 0) status = 'partial'
bills = dr.bills
.filter((b) => b.dispensed > 0)
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
}
persistTransaction({
txid: ctx.txid,
type: isCashInTx ? 'cash_in' : 'cash_out',
type: 'cash_out',
status,
fiatCents: ctx.fiatCents,
sats: ctx.satsAmount,
feeSats: Math.max(0, feeSats),
feeSats: computeFeeSats(ctx, false),
feePercent: ctx.feePercent,
exchangeRate: ctx.exchangeRate,
currency: ctx.currency,
bills,
cassettes: dr?.cassettes,
error: dr?.error ?? ctx.error,
})
}
}
if (currentNested === 'complete' && prevNestedState !== 'complete' && prevSnapshot) {
const ctx = newSnapshot.context
if (ctx.txid) {
const isCashInTx = typeof state === 'object' && 'cashIn' in state
const dr = ctx.dispenseResult
// For cash-out with dispense results, use actual dispensed counts
const bills: { denomination: number; count: number }[] = isCashInTx
? buildBillsFromInserted(ctx.billsInserted)
: dr
? dr.bills.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
: ctx.dispenseAmounts
persistTransaction({
txid: ctx.txid,
type: isCashInTx ? 'cash_in' : 'cash_out',
status: 'complete',
fiatCents: ctx.fiatCents,
sats: ctx.satsAmount,
feeSats: computeFeeSats(ctx, isCashInTx),
feePercent: ctx.feePercent,
exchangeRate: ctx.exchangeRate,
currency: ctx.currency,
bills,
cassettes: dr?.cassettes,
error: dr?.error,
})
}
}

View file

@ -38,6 +38,7 @@ declare global {
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
@ -45,6 +46,15 @@ declare global {
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

@ -16,6 +16,7 @@ export interface CashboxState {
export interface TransactionRecord {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
@ -23,6 +24,15 @@ export interface TransactionRecord {
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
}
export interface ATMAvailability {

View file

@ -51,6 +51,7 @@ export {
type PaymentStatus,
type PaymentMethod,
type DispenseCashResult,
type CassetteBillResult,
initialContext,
} from './types.js'

View file

@ -5,14 +5,32 @@
/** Payment status */
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null
/** Per-cassette dispense result (position-aware) */
export interface CassetteBillResult {
/** Cassette name, e.g. "cassette1", "cassette2" */
name: string
/** 0-indexed physical position */
position: number
/** Bill denomination in this cassette */
denomination: number
/** Bills requested from this cassette */
provisioned: number
/** Bills actually dispensed */
dispensed: number
/** Bills rejected/jammed */
rejected: number
}
/** Result of a dispense operation (always resolves, never throws) */
export interface DispenseCashResult {
/** Per-cassette results (what was actually dispensed) */
/** Per-denomination results (what was actually dispensed) */
bills: { denomination: number; dispensed: number; rejected: number }[]
/** Whether the full requested amount was dispensed */
dispensed: boolean
/** Error message if dispense failed or was partial */
error?: string
/** Per-cassette detail (position-aware, produced by HAL) */
cassettes?: CassetteBillResult[]
}
/** Payment methods supported */