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 }),