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

@ -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-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 && c.denomination in inventory) {
inventory[c.denomination] = (inventory[c.denomination] ?? 0) - 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 (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 {