From e51f462876a82361ffaadfd62cd613a9e79170cf Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 6 Mar 2026 10:01:07 -0500 Subject: [PATCH] fix(machine): align dispense error handling with legacy brain.js dispenseCash now always resolves with a DispenseCashResult (per-bill dispensed/rejected counts, overall success flag, optional error) instead of throwing. dispenseError is a 30s timed state that auto-returns to idle, matching brain.js _timedState('outOfCash'). The dead-end retry loop (which the UI never exposed) is removed. The Vue dispenseError screen now shows partial dispense info, the transaction ID as a QR code, and a 30s countdown. Closes #30 Co-Authored-By: Claude Opus 4.6 --- apps/machine/src/services/hal.ts | 34 ++++- apps/machine/src/services/lightning.ts | 10 +- apps/machine/src/stores/atm.ts | 10 +- apps/machine/src/views/CashOutView.vue | 82 ++++++++++- .../src/__tests__/machine.test.ts | 131 +++++++++++++++++- packages/state-machine/src/index.ts | 1 + packages/state-machine/src/machine.ts | 42 ++++-- packages/state-machine/src/types.ts | 17 ++- 8 files changed, 303 insertions(+), 24 deletions(-) diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index 81c4e98..b9041d3 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -126,14 +126,22 @@ export async function initializeHalServices(config: HalConfig): Promise ({ + denomination: a.denomination, + dispensed: 0, + rejected: 0, + })), + dispensed: false, + error: `No cassette loaded with denomination: ${denomination}`, + } } notes[idx] = count } const result = await dispenser.dispense(notes) - // Update inventory + // 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) { @@ -141,13 +149,27 @@ export async function initializeHalServices(config: HalConfig): Promise ({ + denomination: a.denomination, + dispensed: result.value[i]?.dispensed ?? 0, + rejected: result.value[i]?.rejected ?? 0, + })) + + const totalRequested = amounts.reduce((s, a) => s + a.count, 0) + const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) + if (result.error) { - throw result.error + return { bills, dispensed: false, error: result.error.message } } - // Wait for customer to take the bills - await dispenser.waitForBillsRemoved() - console.log('[HAL] Bills removed by customer') + // Wait for customer to take bills (only if bills were dispensed) + if (totalDispensed > 0) { + await dispenser.waitForBillsRemoved() + console.log('[HAL] Bills removed by customer') + } + + return { bills, dispensed: totalRequested === totalDispensed } }, getInventory: async () => { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 243a1fb..3ffa868 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -1171,7 +1171,7 @@ function createATMServices( /** * Dispense cash (mock for development) */ - dispenseCash: async (amounts: { denomination: number; count: number }[]): Promise => { + dispenseCash: async (amounts) => { console.log('[ATM Service] Dispensing cash:', amounts) // In production, this would interface with the Rust HAL @@ -1179,6 +1179,14 @@ function createATMServices( await new Promise((resolve) => setTimeout(resolve, 2000)) console.log('[ATM Service] Cash dispensed successfully') + return { + bills: amounts.map((a) => ({ + denomination: a.denomination, + dispensed: a.count, + rejected: 0, + })), + dispensed: true, + } }, /** diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 40c9c28..46ac786 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -91,6 +91,14 @@ const mockServices: ATMServices = { dispenseCash: async (amounts) => { console.log('[Mock] Dispensing cash:', amounts) await new Promise((resolve) => setTimeout(resolve, 2000)) + return { + bills: amounts.map((a) => ({ + denomination: a.denomination, + dispensed: a.count, + rejected: 0, + })), + dispensed: true, + } }, // noffer cash-out services @@ -787,7 +795,7 @@ export const useAtmStore = defineStore('atm', () => { const halAtmServices: Pick = { dispenseCash: async (amounts) => { console.log('[ATM] Dispensing via IPC:', amounts) - await api.halDispense(amounts) + return await api.halDispense(amounts) }, getInventory: async () => { // Priority: DB inventory > HAL hardware inventory > empty diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 5eb8d53..d0e7e8f 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -58,6 +58,26 @@ watch( const nestedState = computed(() => atmStore.nestedState) const context = computed(() => atmStore.context) +// Dispense error 30s countdown +const dispenseErrorCountdown = ref(30) +let countdownTimer: ReturnType | null = null + +watch(nestedState, (newState, oldState) => { + if (newState === 'dispenseError' && oldState !== 'dispenseError') { + dispenseErrorCountdown.value = 30 + countdownTimer = setInterval(() => { + dispenseErrorCountdown.value-- + if (dispenseErrorCountdown.value <= 0 && countdownTimer) { + clearInterval(countdownTimer) + countdownTimer = null + } + }, 1000) + } else if (oldState === 'dispenseError' && countdownTimer) { + clearInterval(countdownTimer) + countdownTimer = null + } +}) + // Available denominations from inventory const availableDenominations = computed(() => { if (!context.value?.inventory) return [] @@ -425,9 +445,69 @@ function formatFiat(cents: number): string { + +
+ +
+
⚠️
+

Dispense Error

+

+ {{ context?.error || 'Cash could not be dispensed' }} +

+ + +
+
+ {{ atmStore.fiatSymbol }}{{ bill.denomination }} + + {{ bill.dispensed }} dispensed + + ({{ bill.rejected }} rejected) + + +
+
+ +

+ Please contact support with the transaction ID below. +

+ + +

+ Returning to start in {{ dispenseErrorCountdown }}s +

+ + +
+ + +
+ +

+ {{ context.txid }} +

+
+
+
{ const mockServices: ATMServices = { @@ -10,7 +10,10 @@ describe('ATM State Machine', () => { generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'), generateNdebit: vi.fn().mockResolvedValue('clink:ndebit1test?amount=1000'), sendNostrReceipt: vi.fn().mockResolvedValue(undefined), - dispenseCash: vi.fn().mockResolvedValue(undefined), + dispenseCash: vi.fn().mockResolvedValue({ + bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], + dispensed: true, + } satisfies DispenseCashResult), getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD // noffer cash-out services (legacy) generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'), @@ -224,6 +227,130 @@ describe('ATM State Machine', () => { }) }) + describe('dispense error handling', () => { + it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => { + const machine = createATMMachine(mockServices) + const actor = createActor(machine) + actor.start() + + actor.send({ type: 'SELECT_CASH_OUT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const state = actor.getSnapshot() + // dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken + expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) + expect(state.context.cashDispensed).toBe(true) + expect(state.context.dispenseResult?.dispensed).toBe(true) + }) + + it('should route to dispenseError when dispenseCash returns dispensed: false', async () => { + const failDispenseServices: ATMServices = { + ...mockServices, + dispenseCash: vi.fn().mockResolvedValue({ + bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], + dispensed: false, + error: 'Cassette jam', + } satisfies DispenseCashResult), + } + + const machine = createATMMachine(failDispenseServices) + const actor = createActor(machine) + actor.start() + + actor.send({ type: 'SELECT_CASH_OUT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const state = actor.getSnapshot() + expect(state.value).toMatchObject({ cashOut: 'dispenseError' }) + expect(state.context.cashDispensed).toBe(false) + expect(state.context.dispenseResult?.dispensed).toBe(false) + expect(state.context.dispenseResult?.error).toBe('Cassette jam') + expect(state.context.error).toBe('Cassette jam') + }) + + it('should auto-idle after 30s in dispenseError state', async () => { + vi.useFakeTimers() + + const failDispenseServices: ATMServices = { + ...mockServices, + dispenseCash: vi.fn().mockResolvedValue({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispensed: false, + error: 'Out of cash', + } satisfies DispenseCashResult), + } + + const machine = createATMMachine(failDispenseServices) + const actor = createActor(machine) + actor.start() + + actor.send({ type: 'SELECT_CASH_OUT' }) + await vi.advanceTimersByTimeAsync(100) + + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await vi.advanceTimersByTimeAsync(100) + + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) + await vi.advanceTimersByTimeAsync(100) + + // Should be in dispenseError + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) + + // Advance 30s + await vi.advanceTimersByTimeAsync(30000) + + // Should have auto-idled + expect(actor.getSnapshot().value).toBe('idle') + + vi.useRealTimers() + }) + + it('should allow CANCEL from dispenseError to go to idle immediately', async () => { + const failDispenseServices: ATMServices = { + ...mockServices, + dispenseCash: vi.fn().mockResolvedValue({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispensed: false, + error: 'Jam', + } satisfies DispenseCashResult), + } + + const machine = createATMMachine(failDispenseServices) + const actor = createActor(machine) + actor.start() + + actor.send({ type: 'SELECT_CASH_OUT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) + + actor.send({ type: 'CANCEL' }) + expect(actor.getSnapshot().value).toBe('idle') + }) + }) + describe('error handling', () => { it('should transition to error state on service failure', async () => { const failingServices: ATMServices = { diff --git a/packages/state-machine/src/index.ts b/packages/state-machine/src/index.ts index 03cc3f4..c23ee48 100644 --- a/packages/state-machine/src/index.ts +++ b/packages/state-machine/src/index.ts @@ -50,6 +50,7 @@ export { type ATMServices, type PaymentStatus, type PaymentMethod, + type DispenseCashResult, initialContext, } from './types.js' diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index a3ebd35..57f07b5 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -9,6 +9,7 @@ import { setup, assign, fromPromise, fromCallback } from 'xstate' import { type ATMContext, type ATMEvent, + type DispenseCashResult, initialContext, type ATMServices, type OfferRequestEvent, @@ -161,6 +162,7 @@ export function createATMMachine( cashInFeePercent: context.cashInFeePercent, cashOutFeePercent: context.cashOutFeePercent, cashInSessionId: null, + dispenseResult: null, })), setStartTime: assign({ startedAt: () => Date.now(), @@ -265,6 +267,16 @@ export function createATMMachine( setCashDispensed: assign({ cashDispensed: () => true, }), + setDispenseResult: assign({ + dispenseResult: ({ event }) => { + const output = (event as unknown as { output: DispenseCashResult }).output + return output ?? null + }, + error: ({ event }) => { + const output = (event as unknown as { output: DispenseCashResult }).output + return output?.error ?? null + }, + }), setAmount: assign({ fiatAmount: ({ event }) => { if (event.type !== 'SELECT_AMOUNT') return 0 @@ -380,6 +392,7 @@ export function createATMMachine( delays: { TIMEOUT_MS: 300000, // 5 minutes COMPLETE_DELAY: 60000, + DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState }, }).createMachine({ id: 'atm', @@ -644,11 +657,20 @@ export function createATMMachine( invoke: { src: 'dispenseCash', input: ({ context }) => context.dispenseAmounts, - onDone: { - target: 'waitingForCashTaken', - actions: 'setCashDispensed', - }, + onDone: [ + { + guard: ({ event }) => (event.output as unknown as DispenseCashResult).dispensed, + target: 'waitingForCashTaken', + actions: ['setCashDispensed', 'setDispenseResult'], + }, + { + // Partial or failed dispense + target: 'dispenseError', + actions: 'setDispenseResult', + }, + ], onError: { + // Unexpected crash (not a dispense failure) target: 'dispenseError', actions: 'setError', }, @@ -688,14 +710,12 @@ export function createATMMachine( }, }, dispenseError: { - // Critical error - payment received but cash not dispensed - // Requires manual intervention + // Payment received but cash not (fully) dispensed. + // Show error + txid for 30s, then auto-idle (matches brain.js _timedState). + after: { + DISPENSE_ERROR_TIMEOUT: '#atm.idle', + }, on: { - RETRY: { - guard: 'canRetry', - target: 'dispensingCash', - actions: 'incrementRetry', - }, CANCEL: '#atm.idle', }, }, diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index 5960951..c26aa2f 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -5,6 +5,16 @@ /** Payment status */ export type PaymentStatus = 'pending' | 'paid' | 'failed' | null +/** Result of a dispense operation (always resolves, never throws) */ +export interface DispenseCashResult { + /** Per-cassette 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 +} + /** Payment methods supported */ export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' @@ -81,6 +91,8 @@ export interface ATMContext { error: string | null /** Retry count for recoverable errors */ retryCount: number + /** Result from the last dispense operation */ + dispenseResult: DispenseCashResult | null // Transaction metadata /** Unique transaction ID */ @@ -155,6 +167,7 @@ export const initialContext: ATMContext = { userNpub: null, error: null, retryCount: 0, + dispenseResult: null, txid: null, startedAt: null, cashInSessionId: null, @@ -172,8 +185,8 @@ export interface ATMServices { generateInvoice: (amountMsat: number) => Promise /** Send receipt via Nostr */ sendNostrReceipt: (context: ATMContext) => Promise - /** Dispense cash */ - dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise + /** Dispense cash (always resolves with result, never throws) */ + dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise /** Get current exchange rate */ getExchangeRate: (currency: string) => Promise /** Get ATM's available balance in sats (for limiting cash-in transactions) */