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 <noreply@anthropic.com>
This commit is contained in:
parent
143ae9ff0b
commit
e51f462876
8 changed files with 303 additions and 24 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createActor } from 'xstate'
|
||||
import { createATMMachine } from '../machine.js'
|
||||
import type { ATMServices, OfferRequestEvent } from '../types.js'
|
||||
import type { ATMServices, OfferRequestEvent, DispenseCashResult } from '../types.js'
|
||||
|
||||
describe('ATM State Machine', () => {
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export {
|
|||
type ATMServices,
|
||||
type PaymentStatus,
|
||||
type PaymentMethod,
|
||||
type DispenseCashResult,
|
||||
initialContext,
|
||||
} from './types.js'
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
/** Send receipt via Nostr */
|
||||
sendNostrReceipt: (context: ATMContext) => Promise<void>
|
||||
/** Dispense cash */
|
||||
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<void>
|
||||
/** Dispense cash (always resolves with result, never throws) */
|
||||
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<DispenseCashResult>
|
||||
/** Get current exchange rate */
|
||||
getExchangeRate: (currency: string) => Promise<number>
|
||||
/** Get ATM's available balance in sats (for limiting cash-in transactions) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue