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,14 +150,14 @@ alice-pay <invoice>
For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides: For advanced testing with multiple Lightning implementations, use the regtest environment at `~/dev/local/docker/regtest`. This provides:
| Service | Description | | Service | Description |
| ----------------- | ------------------------------------- | | ------------------ | -------------------------------------------------------------- |
| 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) | | 4 LND nodes | lnd-1 (hub), lnd-2 (Boltz), lnd-3 (LNbits), lnd-4 (standalone) |
| 3 CLN nodes | Core Lightning with REST/gRPC | | 3 CLN nodes | Core Lightning with REST/gRPC |
| 1 Eclair node | ACINQ Eclair implementation | | 1 Eclair node | ACINQ Eclair implementation |
| LNbits | Lightning wallet platform (port 5001) | | LNbits | Lightning wallet platform (port 5001) |
| Boltz | Submarine swaps (port 9001) | | Boltz | Submarine swaps (port 9001) |
| Electrs | Electrum server (port 3002) | | Electrs | Electrum server (port 3002) |
| Lightning Terminal| Web UI for lnd-1 (port 8443) | | Lightning Terminal | Web UI for lnd-1 (port 8443) |
| Elements/Liquid | Sidechain (port 18884) | | Elements/Liquid | Sidechain (port 18884) |
```bash ```bash
@ -289,13 +289,26 @@ Drivers are ported from `lamassu-machine/lib/`:
| ---------- | ------------------------------------------------------------ | | ---------- | ------------------------------------------------------------ |
| Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 | | Validators | id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 |
| Dispensers | puloon, f56, genmega, hcm2, gsr50 | | Dispensers | puloon, f56, genmega, hcm2, gsr50 |
| Recyclers | MEI SCR (planned — hardware exists in BATM3, no driver yet) |
| Printers | nippon, zebra, genmega | | 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: When porting:
1. Read JS driver thoroughly 1. Read JS driver thoroughly
2. Document protocol from JS code 2. Document protocol from JS code
3. Implement Rust version 3. Implement TypeScript version
4. Test against same hardware 4. Test against same hardware
5. Use `/hal-check port <driver>` to validate 5. Use `/hal-check port <driver>` to validate

View file

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

View file

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

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '3' const SCHEMA_VERSION = '4'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/lamassu-atm' const prodDir = '/var/lib/lamassu-atm'
@ -72,6 +72,8 @@ export function initDatabase(dbPath?: string): void {
fee_percent REAL NOT NULL DEFAULT 0, fee_percent REAL NOT NULL DEFAULT 0,
exchange_rate REAL NOT NULL DEFAULT 0, exchange_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'GTQ', currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete',
error TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@ -81,6 +83,17 @@ export function initDatabase(dbPath?: string): void {
denomination INTEGER NOT NULL, denomination INTEGER NOT NULL,
count 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 // 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') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('3', 'schema_version')
console.log('[StateStore] Migrated schema v2 → v3 (added exchange_rate, currency)') 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() const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
@ -235,6 +270,7 @@ export function emptyCashbox(): void {
interface TransactionInput { interface TransactionInput {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -242,6 +278,15 @@ interface TransactionInput {
exchangeRate: number exchangeRate: number
currency: string currency: string
bills: { denomination: number; count: number }[] 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') if (!db) throw new Error('Database not initialized')
const insertTx = db.prepare( 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( const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' '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( const updateCassette = db.prepare(
'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?' 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?'
) )
@ -267,6 +315,8 @@ export function recordTransaction(tx: TransactionInput): void {
insertTx.run( insertTx.run(
t.txid, t.txid,
t.type, t.type,
t.status,
t.error ?? null,
t.fiatCents, t.fiatCents,
t.sats, t.sats,
t.feeSats, t.feeSats,
@ -278,12 +328,38 @@ export function recordTransaction(tx: TransactionInput): void {
for (const bill of t.bills) { for (const bill of t.bills) {
insertBill.run(t.txid, bill.denomination, bill.count) 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') { 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) updateCassette.run(-bill.count, bill.denomination)
} }
} }
}
if (t.type === 'cash_in') { if (t.type === 'cash_in') {
// Bills inserted by customer go into cashbox // Bills inserted by customer go into cashbox
@ -293,7 +369,7 @@ export function recordTransaction(tx: TransactionInput): void {
}) })
run(tx) 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) const result = await dispenser.dispense(notes)
// Update inventory based on what was ACTUALLY dispensed // Build per-cassette results (position-aware, matches result.value ordering)
for (let i = 0; i < result.value.length; i++) { const cassetteResults = cassetteDenominations.map((denom, i) => ({
const denom = cassetteDenominations[i] name: `cassette${i + 1}`,
if (denom !== undefined && inventory[denom] !== undefined) { position: i,
inventory[denom] -= result.value[i]?.dispensed ?? 0 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 // Build per-denomination result (backward compatible, only requested denoms)
const bills = amounts.map((a, i) => ({ const bills = cassetteResults
denomination: a.denomination, .filter((c) => c.provisioned > 0)
dispensed: result.value[i]?.dispensed ?? 0, .map((c) => ({
rejected: result.value[i]?.rejected ?? 0, denomination: c.denomination,
dispensed: c.dispensed,
rejected: c.rejected,
})) }))
const totalRequested = amounts.reduce((s, a) => s + a.count, 0) const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
if (result.error) { 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) // 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') console.log('[HAL] Bills removed by customer')
} }
return { bills, dispensed: totalRequested === totalDispensed } return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
}, },
getInventory: async () => { getInventory: async () => {

View file

@ -19,6 +19,18 @@ import type { TransactionRecord } from '@/types/state'
// Check if we're running in Electron // Check if we're running in Electron
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined 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). * Load inventory from SQLite via IPC (Electron only).
* Returns empty object in browser dev mode. * Returns empty object in browser dev mode.
@ -261,31 +273,67 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CASH_DISPENSED' }) 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 const ctx = newSnapshot.context
if (ctx.txid) { if (ctx.txid) {
const isCashInTx = typeof state === 'object' && 'cashIn' in state const dr = ctx.dispenseResult
const bills: { denomination: number; count: number }[] = isCashInTx
? buildBillsFromInserted(ctx.billsInserted)
: ctx.dispenseAmounts
// Compute fee in sats from context // Determine status from dispense result (if available)
const grossSats = let status: 'dispense_error' | 'partial' = 'dispense_error'
ctx.exchangeRate > 0 ? Math.floor((ctx.fiatCents / 100) * ctx.exchangeRate) : 0 let bills: { denomination: number; count: number }[] = []
const feeSats = isCashInTx
? grossSats - ctx.satsAmount // cash-in: customer gets net (gross - fee) if (dr) {
: ctx.satsAmount - grossSats // cash-out: customer pays gross + fee 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({ persistTransaction({
txid: ctx.txid, txid: ctx.txid,
type: isCashInTx ? 'cash_in' : 'cash_out', type: 'cash_out',
status,
fiatCents: ctx.fiatCents, fiatCents: ctx.fiatCents,
sats: ctx.satsAmount, sats: ctx.satsAmount,
feeSats: Math.max(0, feeSats), feeSats: computeFeeSats(ctx, false),
feePercent: ctx.feePercent, feePercent: ctx.feePercent,
exchangeRate: ctx.exchangeRate, exchangeRate: ctx.exchangeRate,
currency: ctx.currency, currency: ctx.currency,
bills, 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: { recordTransaction: (tx: {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -45,6 +46,15 @@ declare global {
exchangeRate: number exchangeRate: number
currency: string currency: string
bills: { denomination: number; count: number }[] bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => Promise<void> }) => Promise<void>
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
// HAL hardware IPC // HAL hardware IPC

View file

@ -16,6 +16,7 @@ export interface CashboxState {
export interface TransactionRecord { export interface TransactionRecord {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -23,6 +24,15 @@ export interface TransactionRecord {
exchangeRate: number exchangeRate: number
currency: string currency: string
bills: { denomination: number; count: number }[] bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
} }
export interface ATMAvailability { export interface ATMAvailability {

View file

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

View file

@ -5,14 +5,32 @@
/** Payment status */ /** Payment status */
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null 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) */ /** Result of a dispense operation (always resolves, never throws) */
export interface DispenseCashResult { export interface DispenseCashResult {
/** Per-cassette results (what was actually dispensed) */ /** Per-denomination results (what was actually dispensed) */
bills: { denomination: number; dispensed: number; rejected: number }[] bills: { denomination: number; dispensed: number; rejected: number }[]
/** Whether the full requested amount was dispensed */ /** Whether the full requested amount was dispensed */
dispensed: boolean dispensed: boolean
/** Error message if dispense failed or was partial */ /** Error message if dispense failed or was partial */
error?: string error?: string
/** Per-cassette detail (position-aware, produced by HAL) */
cassettes?: CassetteBillResult[]
} }
/** Payment methods supported */ /** Payment methods supported */