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
|
|
@ -126,14 +126,22 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
for (const { denomination, count } of amounts) {
|
for (const { denomination, count } of amounts) {
|
||||||
const idx = cassetteDenominations.indexOf(denomination)
|
const idx = cassetteDenominations.indexOf(denomination)
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
throw new Error(`No cassette loaded with denomination: ${denomination}`)
|
return {
|
||||||
|
bills: amounts.map((a) => ({
|
||||||
|
denomination: a.denomination,
|
||||||
|
dispensed: 0,
|
||||||
|
rejected: 0,
|
||||||
|
})),
|
||||||
|
dispensed: false,
|
||||||
|
error: `No cassette loaded with denomination: ${denomination}`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
notes[idx] = count
|
notes[idx] = count
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dispenser.dispense(notes)
|
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++) {
|
for (let i = 0; i < result.value.length; i++) {
|
||||||
const denom = cassetteDenominations[i]
|
const denom = cassetteDenominations[i]
|
||||||
if (denom !== undefined && inventory[denom] !== undefined) {
|
if (denom !== undefined && inventory[denom] !== undefined) {
|
||||||
|
|
@ -141,13 +149,27 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build per-denomination result
|
||||||
|
const bills = amounts.map((a, i) => ({
|
||||||
|
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) {
|
if (result.error) {
|
||||||
throw result.error
|
return { bills, dispensed: false, error: result.error.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for customer to take the bills
|
// Wait for customer to take bills (only if bills were dispensed)
|
||||||
await dispenser.waitForBillsRemoved()
|
if (totalDispensed > 0) {
|
||||||
console.log('[HAL] Bills removed by customer')
|
await dispenser.waitForBillsRemoved()
|
||||||
|
console.log('[HAL] Bills removed by customer')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bills, dispensed: totalRequested === totalDispensed }
|
||||||
},
|
},
|
||||||
|
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
|
|
|
||||||
|
|
@ -1171,7 +1171,7 @@ function createATMServices(
|
||||||
/**
|
/**
|
||||||
* Dispense cash (mock for development)
|
* Dispense cash (mock for development)
|
||||||
*/
|
*/
|
||||||
dispenseCash: async (amounts: { denomination: number; count: number }[]): Promise<void> => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log('[ATM Service] Dispensing cash:', amounts)
|
console.log('[ATM Service] Dispensing cash:', amounts)
|
||||||
|
|
||||||
// In production, this would interface with the Rust HAL
|
// In production, this would interface with the Rust HAL
|
||||||
|
|
@ -1179,6 +1179,14 @@ function createATMServices(
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
|
|
||||||
console.log('[ATM Service] Cash dispensed successfully')
|
console.log('[ATM Service] Cash dispensed successfully')
|
||||||
|
return {
|
||||||
|
bills: amounts.map((a) => ({
|
||||||
|
denomination: a.denomination,
|
||||||
|
dispensed: a.count,
|
||||||
|
rejected: 0,
|
||||||
|
})),
|
||||||
|
dispensed: true,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,14 @@ const mockServices: ATMServices = {
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log('[Mock] Dispensing cash:', amounts)
|
console.log('[Mock] Dispensing cash:', amounts)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
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
|
// noffer cash-out services
|
||||||
|
|
@ -787,7 +795,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
const halAtmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
const halAtmServices: Pick<ATMServices, 'dispenseCash' | 'getInventory'> = {
|
||||||
dispenseCash: async (amounts) => {
|
dispenseCash: async (amounts) => {
|
||||||
console.log('[ATM] Dispensing via IPC:', amounts)
|
console.log('[ATM] Dispensing via IPC:', amounts)
|
||||||
await api.halDispense(amounts)
|
return await api.halDispense(amounts)
|
||||||
},
|
},
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
// Priority: DB inventory > HAL hardware inventory > empty
|
// Priority: DB inventory > HAL hardware inventory > empty
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,26 @@ watch(
|
||||||
const nestedState = computed(() => atmStore.nestedState)
|
const nestedState = computed(() => atmStore.nestedState)
|
||||||
const context = computed(() => atmStore.context)
|
const context = computed(() => atmStore.context)
|
||||||
|
|
||||||
|
// Dispense error 30s countdown
|
||||||
|
const dispenseErrorCountdown = ref(30)
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | 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
|
// Available denominations from inventory
|
||||||
const availableDenominations = computed(() => {
|
const availableDenominations = computed(() => {
|
||||||
if (!context.value?.inventory) return []
|
if (!context.value?.inventory) return []
|
||||||
|
|
@ -425,9 +445,69 @@ function formatFiat(cents: number): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dispense Error (timed, 30s → idle) -->
|
||||||
|
<div
|
||||||
|
v-else-if="nestedState === 'dispenseError'"
|
||||||
|
key="dispenseError"
|
||||||
|
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
|
||||||
|
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
||||||
|
>
|
||||||
|
<!-- Left side — error details -->
|
||||||
|
<div class="flex flex-col items-center gap-3 lg:gap-5">
|
||||||
|
<div class="text-5xl lg:text-[8vh]">⚠️</div>
|
||||||
|
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3>
|
||||||
|
<p class="text-base lg:text-2xl text-muted-foreground">
|
||||||
|
{{ context?.error || 'Cash could not be dispensed' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Partial dispense info -->
|
||||||
|
<div
|
||||||
|
v-if="context?.dispenseResult?.bills?.length"
|
||||||
|
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="bill in context.dispenseResult.bills"
|
||||||
|
:key="bill.denomination"
|
||||||
|
class="flex justify-between text-base lg:text-2xl"
|
||||||
|
>
|
||||||
|
<span class="text-muted-foreground"
|
||||||
|
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span
|
||||||
|
>
|
||||||
|
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
|
||||||
|
{{ bill.dispensed }} dispensed
|
||||||
|
<span v-if="bill.rejected > 0" class="text-destructive">
|
||||||
|
({{ bill.rejected }} rejected)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm lg:text-lg text-muted-foreground">
|
||||||
|
Please contact support with the transaction ID below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Countdown -->
|
||||||
|
<p class="text-sm lg:text-base text-muted-foreground">
|
||||||
|
Returning to start in {{ dispenseErrorCountdown }}s
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side — txid QR -->
|
||||||
|
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
|
||||||
|
<QRCode :value="context.txid" :size="280" />
|
||||||
|
<p
|
||||||
|
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
|
||||||
|
>
|
||||||
|
{{ context.txid }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Error (centered, destructive accent) -->
|
<!-- Error (centered, destructive accent) -->
|
||||||
<div
|
<div
|
||||||
v-else-if="nestedState === 'error' || nestedState === 'dispenseError'"
|
v-else-if="nestedState === 'error'"
|
||||||
key="error"
|
key="error"
|
||||||
class="flex flex-1 flex-col items-center justify-center gap-4 lg:gap-6 p-4 lg:p-8"
|
class="flex flex-1 flex-col items-center justify-center gap-4 lg:gap-6 p-4 lg:p-8"
|
||||||
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { createActor } from 'xstate'
|
import { createActor } from 'xstate'
|
||||||
import { createATMMachine } from '../machine.js'
|
import { createATMMachine } from '../machine.js'
|
||||||
import type { ATMServices, OfferRequestEvent } from '../types.js'
|
import type { ATMServices, OfferRequestEvent, DispenseCashResult } from '../types.js'
|
||||||
|
|
||||||
describe('ATM State Machine', () => {
|
describe('ATM State Machine', () => {
|
||||||
const mockServices: ATMServices = {
|
const mockServices: ATMServices = {
|
||||||
|
|
@ -10,7 +10,10 @@ describe('ATM State Machine', () => {
|
||||||
generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'),
|
generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'),
|
||||||
generateNdebit: vi.fn().mockResolvedValue('clink:ndebit1test?amount=1000'),
|
generateNdebit: vi.fn().mockResolvedValue('clink:ndebit1test?amount=1000'),
|
||||||
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
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
|
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
||||||
// noffer cash-out services (legacy)
|
// noffer cash-out services (legacy)
|
||||||
generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'),
|
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', () => {
|
describe('error handling', () => {
|
||||||
it('should transition to error state on service failure', async () => {
|
it('should transition to error state on service failure', async () => {
|
||||||
const failingServices: ATMServices = {
|
const failingServices: ATMServices = {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export {
|
||||||
type ATMServices,
|
type ATMServices,
|
||||||
type PaymentStatus,
|
type PaymentStatus,
|
||||||
type PaymentMethod,
|
type PaymentMethod,
|
||||||
|
type DispenseCashResult,
|
||||||
initialContext,
|
initialContext,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { setup, assign, fromPromise, fromCallback } from 'xstate'
|
||||||
import {
|
import {
|
||||||
type ATMContext,
|
type ATMContext,
|
||||||
type ATMEvent,
|
type ATMEvent,
|
||||||
|
type DispenseCashResult,
|
||||||
initialContext,
|
initialContext,
|
||||||
type ATMServices,
|
type ATMServices,
|
||||||
type OfferRequestEvent,
|
type OfferRequestEvent,
|
||||||
|
|
@ -161,6 +162,7 @@ export function createATMMachine(
|
||||||
cashInFeePercent: context.cashInFeePercent,
|
cashInFeePercent: context.cashInFeePercent,
|
||||||
cashOutFeePercent: context.cashOutFeePercent,
|
cashOutFeePercent: context.cashOutFeePercent,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
|
dispenseResult: null,
|
||||||
})),
|
})),
|
||||||
setStartTime: assign({
|
setStartTime: assign({
|
||||||
startedAt: () => Date.now(),
|
startedAt: () => Date.now(),
|
||||||
|
|
@ -265,6 +267,16 @@ export function createATMMachine(
|
||||||
setCashDispensed: assign({
|
setCashDispensed: assign({
|
||||||
cashDispensed: () => true,
|
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({
|
setAmount: assign({
|
||||||
fiatAmount: ({ event }) => {
|
fiatAmount: ({ event }) => {
|
||||||
if (event.type !== 'SELECT_AMOUNT') return 0
|
if (event.type !== 'SELECT_AMOUNT') return 0
|
||||||
|
|
@ -380,6 +392,7 @@ export function createATMMachine(
|
||||||
delays: {
|
delays: {
|
||||||
TIMEOUT_MS: 300000, // 5 minutes
|
TIMEOUT_MS: 300000, // 5 minutes
|
||||||
COMPLETE_DELAY: 60000,
|
COMPLETE_DELAY: 60000,
|
||||||
|
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
||||||
},
|
},
|
||||||
}).createMachine({
|
}).createMachine({
|
||||||
id: 'atm',
|
id: 'atm',
|
||||||
|
|
@ -644,11 +657,20 @@ export function createATMMachine(
|
||||||
invoke: {
|
invoke: {
|
||||||
src: 'dispenseCash',
|
src: 'dispenseCash',
|
||||||
input: ({ context }) => context.dispenseAmounts,
|
input: ({ context }) => context.dispenseAmounts,
|
||||||
onDone: {
|
onDone: [
|
||||||
target: 'waitingForCashTaken',
|
{
|
||||||
actions: 'setCashDispensed',
|
guard: ({ event }) => (event.output as unknown as DispenseCashResult).dispensed,
|
||||||
},
|
target: 'waitingForCashTaken',
|
||||||
|
actions: ['setCashDispensed', 'setDispenseResult'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Partial or failed dispense
|
||||||
|
target: 'dispenseError',
|
||||||
|
actions: 'setDispenseResult',
|
||||||
|
},
|
||||||
|
],
|
||||||
onError: {
|
onError: {
|
||||||
|
// Unexpected crash (not a dispense failure)
|
||||||
target: 'dispenseError',
|
target: 'dispenseError',
|
||||||
actions: 'setError',
|
actions: 'setError',
|
||||||
},
|
},
|
||||||
|
|
@ -688,14 +710,12 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispenseError: {
|
dispenseError: {
|
||||||
// Critical error - payment received but cash not dispensed
|
// Payment received but cash not (fully) dispensed.
|
||||||
// Requires manual intervention
|
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
||||||
|
after: {
|
||||||
|
DISPENSE_ERROR_TIMEOUT: '#atm.idle',
|
||||||
|
},
|
||||||
on: {
|
on: {
|
||||||
RETRY: {
|
|
||||||
guard: 'canRetry',
|
|
||||||
target: 'dispensingCash',
|
|
||||||
actions: 'incrementRetry',
|
|
||||||
},
|
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@
|
||||||
/** Payment status */
|
/** Payment status */
|
||||||
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null
|
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 */
|
/** Payment methods supported */
|
||||||
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
||||||
|
|
||||||
|
|
@ -81,6 +91,8 @@ export interface ATMContext {
|
||||||
error: string | null
|
error: string | null
|
||||||
/** Retry count for recoverable errors */
|
/** Retry count for recoverable errors */
|
||||||
retryCount: number
|
retryCount: number
|
||||||
|
/** Result from the last dispense operation */
|
||||||
|
dispenseResult: DispenseCashResult | null
|
||||||
|
|
||||||
// Transaction metadata
|
// Transaction metadata
|
||||||
/** Unique transaction ID */
|
/** Unique transaction ID */
|
||||||
|
|
@ -155,6 +167,7 @@ export const initialContext: ATMContext = {
|
||||||
userNpub: null,
|
userNpub: null,
|
||||||
error: null,
|
error: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
|
dispenseResult: null,
|
||||||
txid: null,
|
txid: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
|
|
@ -172,8 +185,8 @@ export interface ATMServices {
|
||||||
generateInvoice: (amountMsat: number) => Promise<string>
|
generateInvoice: (amountMsat: number) => Promise<string>
|
||||||
/** Send receipt via Nostr */
|
/** Send receipt via Nostr */
|
||||||
sendNostrReceipt: (context: ATMContext) => Promise<void>
|
sendNostrReceipt: (context: ATMContext) => Promise<void>
|
||||||
/** Dispense cash */
|
/** Dispense cash (always resolves with result, never throws) */
|
||||||
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<void>
|
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<DispenseCashResult>
|
||||||
/** Get current exchange rate */
|
/** Get current exchange rate */
|
||||||
getExchangeRate: (currency: string) => Promise<number>
|
getExchangeRate: (currency: string) => Promise<number>
|
||||||
/** Get ATM's available balance in sats (for limiting cash-in transactions) */
|
/** Get ATM's available balance in sats (for limiting cash-in transactions) */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue