feat(state-machine): dispenseConfirmed on value, dispenseFault vs outOfCash, cash-out latch (ADR-005 §3–§5)
DispenseCashResult.dispensed (a driver boolean) is replaced by dispenseConfirmed — Σ(denomination × dispensed) equals the requested value, computed by the HAL — plus errorCode / rawCode / errorClass. dispensingCash.onDone guards on dispenseConfirmed and nothing else. The single dispenseError state becomes two. dispenseFault: the dispenser reported an error, the customer has paid and is owed — 120 s screen with evidence, ACKNOWLEDGE_FAULT to dismiss. outOfCash: a shortfall with no hardware error or an inventory refusal — 30 s. A hung dispense is a terminal fault. A terminal errorClass latches cash-out off: context.cashOutHeld, set by latchCashOutIfTerminal, preserved across resetContext (it is machine health, not transaction state), guarding idle's SELECT_CASH_OUT. Cash-in is unaffected. Only CASH_OUT_RELEASED clears it — the store sends that when an operator recount or resume_cash_out op lands; re-initialising the dispenser never does, because re-init does not move a stuck note. CASH_OUT_HELD lets the store restore a persisted hold on boot. PAYMENT_RECEIVED now carries the payment hash into context.paymentHash so the fault screen can show the reference the server indexes. Tests: the dispense section is rewritten around outcomes — value confirmation, fault vs out-of-cash routing, terminal latch + release, recoverable does not latch, partial-with-error is a fault, inventory refusal is out-of-cash, boot-restored hold gates, 30 s vs 120 s timers, acknowledge/cancel, timeout latches. 46/46.
This commit is contained in:
parent
c70d43523c
commit
3ff86de4ed
3 changed files with 347 additions and 115 deletions
|
|
@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
|
|||
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
dispenseCash: vi.fn().mockResolvedValue({
|
||||
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
||||
dispensed: true,
|
||||
dispenseConfirmed: true,
|
||||
} satisfies DispenseCashResult),
|
||||
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
||||
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
|
||||
|
|
@ -397,127 +397,224 @@ 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)
|
||||
describe('dispense outcome (ADR-005 §3–§5)', () => {
|
||||
/** Drive a 1 × $20 cash-out to the dispense and return the actor. */
|
||||
async function dispenseWith(result: DispenseCashResult, fake = false) {
|
||||
const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) }
|
||||
const actor = createActor(createATMMachine(services))
|
||||
actor.start()
|
||||
|
||||
const tick = async (ms: number) =>
|
||||
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
await tick(100)
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
await tick(100)
|
||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await tick(100)
|
||||
return actor
|
||||
}
|
||||
|
||||
it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
||||
dispenseConfirmed: true,
|
||||
})
|
||||
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)
|
||||
expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true)
|
||||
expect(state.context.cashOutHeld).toBeNull()
|
||||
})
|
||||
|
||||
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))
|
||||
|
||||
it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'Note stopped at the cassette exit',
|
||||
errorCode: 'F56DispenseError',
|
||||
rawCode: '78 42',
|
||||
errorClass: 'terminal',
|
||||
})
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashOut: 'dispenseError' })
|
||||
expect(state.value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||
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')
|
||||
expect(state.context.error).toBe('Note stopped at the cassette exit')
|
||||
expect(state.context.dispenseResult?.rawCode).toBe('78 42')
|
||||
})
|
||||
|
||||
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' })
|
||||
it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'jam',
|
||||
errorCode: 'F56DispenseError',
|
||||
rawCode: '78 42',
|
||||
errorClass: 'terminal',
|
||||
})
|
||||
const hold = actor.getSnapshot().context.cashOutHeld
|
||||
expect(hold).not.toBeNull()
|
||||
expect(hold?.errorCode).toBe('F56DispenseError')
|
||||
expect(hold?.rawCode).toBe('78 42')
|
||||
expect(typeof hold?.since).toBe('number')
|
||||
|
||||
actor.send({ type: 'CANCEL' })
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
// The hold survives resetContext — it is machine health, not transaction state.
|
||||
expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
|
||||
// Only an operator op releases it (the store sends this on recount / resume_cash_out).
|
||||
actor.send({ type: 'CASH_OUT_RELEASED' })
|
||||
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() })
|
||||
})
|
||||
|
||||
it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => {
|
||||
const actor = createActor(createATMMachine(mockServices))
|
||||
actor.start()
|
||||
actor.send({
|
||||
type: 'CASH_OUT_HELD',
|
||||
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 },
|
||||
})
|
||||
actor.send({ type: 'SELECT_CASH_IN' })
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() })
|
||||
})
|
||||
|
||||
it('a recoverable fault shows the fault screen but does NOT latch', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'Bill length check failed (long)',
|
||||
errorCode: 'F56DispenseError',
|
||||
rawCode: '82 00',
|
||||
errorClass: 'recoverable',
|
||||
})
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
|
||||
})
|
||||
|
||||
it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 1, rejected: 1 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'jam after first note',
|
||||
errorCode: 'F56DispenseError',
|
||||
rawCode: '78 42',
|
||||
errorClass: 'terminal',
|
||||
})
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||
})
|
||||
|
||||
it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'Insufficient inventory for denomination 20: short 1',
|
||||
errorCode: 'InsufficientInventory',
|
||||
errorClass: 'inventory',
|
||||
})
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
|
||||
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
|
||||
})
|
||||
|
||||
it('a shortfall with NO error is outOfCash', async () => {
|
||||
const actor = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
})
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
|
||||
})
|
||||
|
||||
it('a persisted hold restored on boot gates cash-out before any dispense', () => {
|
||||
const actor = createActor(createATMMachine(mockServices))
|
||||
actor.start()
|
||||
actor.send({
|
||||
type: 'CASH_OUT_HELD',
|
||||
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 },
|
||||
})
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353)
|
||||
})
|
||||
|
||||
it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ooc = await dispenseWith(
|
||||
{ bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false },
|
||||
true
|
||||
)
|
||||
expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(ooc.getSnapshot().value).toBe('idle')
|
||||
|
||||
const fault = await dispenseWith(
|
||||
{
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'jam',
|
||||
errorCode: 'F56DispenseError',
|
||||
errorClass: 'recoverable',
|
||||
},
|
||||
true
|
||||
)
|
||||
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||
await vi.advanceTimersByTimeAsync(30000)
|
||||
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||
await vi.advanceTimersByTimeAsync(90000)
|
||||
expect(fault.getSnapshot().value).toBe('idle')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => {
|
||||
const a = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'jam',
|
||||
errorClass: 'recoverable',
|
||||
})
|
||||
a.send({ type: 'ACKNOWLEDGE_FAULT' })
|
||||
expect(a.getSnapshot().value).toBe('idle')
|
||||
|
||||
const b = await dispenseWith({
|
||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||
dispenseConfirmed: false,
|
||||
error: 'jam',
|
||||
errorClass: 'recoverable',
|
||||
})
|
||||
b.send({ type: 'CANCEL' })
|
||||
expect(b.getSnapshot().value).toBe('idle')
|
||||
})
|
||||
|
||||
it('a hung dispense (timeout) is a terminal fault and latches', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const hang: ATMServices = {
|
||||
...mockServices,
|
||||
dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||
}
|
||||
const actor = createActor(createATMMachine(hang))
|
||||
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: 'p' })
|
||||
await vi.advanceTimersByTimeAsync(120000 + 10)
|
||||
const s = actor.getSnapshot()
|
||||
expect(s.value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||
expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout')
|
||||
expect(s.context.cashOutHeld).not.toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type ATMContext,
|
||||
type ATMEvent,
|
||||
type DispenseCashResult,
|
||||
type CashOutHold,
|
||||
initialContext,
|
||||
type ATMServices,
|
||||
type OfferRequestEvent,
|
||||
|
|
@ -148,8 +149,8 @@ export function createATMMachine(
|
|||
}
|
||||
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
|
||||
// The service handles the actual extraction
|
||||
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => {
|
||||
sendBack({ type: 'PAYMENT_RECEIVED', preimage })
|
||||
const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => {
|
||||
sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash })
|
||||
})
|
||||
return cleanup
|
||||
}),
|
||||
|
|
@ -184,6 +185,9 @@ export function createATMMachine(
|
|||
// without this the just-granted session would be wiped. The session is
|
||||
// cleared instead on re-lock (locked's entry), i.e. when access ends.
|
||||
accessSession: context.accessSession,
|
||||
// The cash-out latch is machine health, not transaction state — it
|
||||
// survives every reset until an operator op releases it.
|
||||
cashOutHeld: context.cashOutHeld,
|
||||
cashInSessionId: null,
|
||||
dispenseResult: null,
|
||||
})),
|
||||
|
|
@ -315,6 +319,10 @@ export function createATMMachine(
|
|||
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||
return event.preimage
|
||||
},
|
||||
paymentHash: ({ event }) => {
|
||||
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||
return event.paymentHash ?? null
|
||||
},
|
||||
}),
|
||||
setPaymentFailed: assign({
|
||||
paymentStatus: () => 'failed' as const,
|
||||
|
|
@ -332,6 +340,37 @@ export function createATMMachine(
|
|||
return output?.error ?? null
|
||||
},
|
||||
}),
|
||||
// ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an
|
||||
// existing hold is kept (its `since` is the first fault, which is what
|
||||
// the operator wants to know).
|
||||
latchCashOutIfTerminal: assign({
|
||||
cashOutHeld: ({ context }) => {
|
||||
if (context.cashOutHeld) return context.cashOutHeld
|
||||
const dr = context.dispenseResult
|
||||
if (dr?.errorClass !== 'terminal') return null
|
||||
return {
|
||||
reason: dr.error ?? 'terminal dispenser fault',
|
||||
errorCode: dr.errorCode ?? null,
|
||||
rawCode: dr.rawCode ?? null,
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
} satisfies CashOutHold
|
||||
},
|
||||
}),
|
||||
setCashOutHeld: assign({
|
||||
cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null),
|
||||
}),
|
||||
clearCashOutHeld: assign({ cashOutHeld: null }),
|
||||
setDispenseTimeoutError: assign({
|
||||
error: () => 'Dispense timed out — hardware may be jammed',
|
||||
dispenseResult: ({ context }) =>
|
||||
context.dispenseResult ?? {
|
||||
bills: [],
|
||||
dispenseConfirmed: false,
|
||||
error: 'Dispense timed out — hardware may be jammed',
|
||||
errorCode: 'DispenseTimeout',
|
||||
errorClass: 'terminal' as const,
|
||||
},
|
||||
}),
|
||||
setAmount: assign({
|
||||
fiatCents: ({ event }) => {
|
||||
if (event.type !== 'SELECT_AMOUNT') return 0
|
||||
|
|
@ -458,6 +497,18 @@ export function createATMMachine(
|
|||
return principalSats - fee <= context.availableBalance
|
||||
},
|
||||
// Cash-out guards
|
||||
// ADR-005 §5: no cash-out while latched. The idle screen shows why.
|
||||
cashOutAvailable: ({ context }) => context.cashOutHeld === null,
|
||||
// ADR-005 §3/§4: only value equality completes a dispense.
|
||||
dispenseConfirmed: ({ event }) =>
|
||||
(event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true,
|
||||
// A shortfall WITH a hardware error is a fault (customer paid, is owed);
|
||||
// a shortfall with none, or an inventory refusal, is out-of-cash.
|
||||
dispenseHadFault: ({ event }) => {
|
||||
const out = (event as unknown as { output?: DispenseCashResult }).output
|
||||
if (!out?.error) return false
|
||||
return out.errorClass !== 'inventory'
|
||||
},
|
||||
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
|
||||
canAddDenomination: ({ context, event }) => {
|
||||
if (event.type !== 'ADD_DENOMINATION') return false
|
||||
|
|
@ -477,7 +528,10 @@ export function createATMMachine(
|
|||
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
|
||||
COMPLETE_DELAY: 60000,
|
||||
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
||||
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
||||
DISPENSE_ERROR_TIMEOUT: 30000, // out-of-cash: 30s like brain.js _timedState
|
||||
// Fault screen: the customer has paid and is owed money; give them time
|
||||
// to photograph/write down the reference (ADR-005 §4).
|
||||
DISPENSE_FAULT_TIMEOUT: 120000,
|
||||
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
|
||||
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
||||
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
||||
|
|
@ -513,6 +567,11 @@ export function createATMMachine(
|
|||
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
|
||||
}),
|
||||
},
|
||||
// ADR-005 §5 — the store restores a persisted hold on boot and
|
||||
// releases it when an operator op lands. Root-level so it applies in
|
||||
// any state; it only gates entry to cashOut, never an in-flight sale.
|
||||
CASH_OUT_HELD: { actions: 'setCashOutHeld' },
|
||||
CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' },
|
||||
},
|
||||
states: {
|
||||
// === ACCESS GATE (ADR-003) ===
|
||||
|
|
@ -557,6 +616,7 @@ export function createATMMachine(
|
|||
actions: ['setStartTime', 'setCashInFee'],
|
||||
},
|
||||
SELECT_CASH_OUT: {
|
||||
guard: 'cashOutAvailable',
|
||||
target: 'cashOut',
|
||||
actions: ['setStartTime', 'setCashOutFee'],
|
||||
},
|
||||
|
|
@ -861,13 +921,12 @@ export function createATMMachine(
|
|||
},
|
||||
dispensingCash: {
|
||||
// Safety timeout: if dispenseCash promise hangs (hardware jam,
|
||||
// serial port freeze), don't stay here forever.
|
||||
// serial port freeze), don't stay here forever. A hang is a
|
||||
// terminal fault — the transport state is unknown.
|
||||
after: {
|
||||
DISPENSE_TIMEOUT: {
|
||||
target: 'dispenseError',
|
||||
actions: assign({
|
||||
error: () => 'Dispense timed out — hardware may be jammed',
|
||||
}),
|
||||
target: 'dispenseFault',
|
||||
actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'],
|
||||
},
|
||||
},
|
||||
invoke: {
|
||||
|
|
@ -875,21 +934,31 @@ export function createATMMachine(
|
|||
input: ({ context }) => context.dispenseAmounts,
|
||||
onDone: [
|
||||
{
|
||||
guard: ({ event }) =>
|
||||
(event.output as unknown as DispenseCashResult | undefined)?.dispensed === true,
|
||||
// ADR-005 §3: value equality, nothing else, completes.
|
||||
guard: 'dispenseConfirmed',
|
||||
target: 'waitingForCashTaken',
|
||||
actions: ['setCashDispensed', 'setDispenseResult'],
|
||||
},
|
||||
{
|
||||
// Partial or failed dispense
|
||||
target: 'dispenseError',
|
||||
// ADR-005 §4: a hardware error means the customer has paid
|
||||
// and is owed — the fault screen, with evidence. A terminal
|
||||
// class also latches cash-out off (§5).
|
||||
guard: 'dispenseHadFault',
|
||||
target: 'dispenseFault',
|
||||
actions: ['setDispenseResult', 'latchCashOutIfTerminal'],
|
||||
},
|
||||
{
|
||||
// Shortfall with no hardware error, or an inventory refusal
|
||||
// before anything was asked of the dispenser.
|
||||
target: 'outOfCash',
|
||||
actions: 'setDispenseResult',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
// Unexpected crash (not a dispense failure)
|
||||
target: 'dispenseError',
|
||||
actions: 'setError',
|
||||
// The service threw (not a reported dispense failure). No
|
||||
// per-bay report exists — the store flags counts unverified.
|
||||
target: 'dispenseFault',
|
||||
actions: ['setError', 'latchCashOutIfTerminal'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -930,9 +999,26 @@ export function createATMMachine(
|
|||
CANCEL: '#atm.locked',
|
||||
},
|
||||
},
|
||||
dispenseError: {
|
||||
// Payment received but cash not (fully) dispensed.
|
||||
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
||||
// ADR-005 §4 — two terminal states replace the old dispenseError.
|
||||
//
|
||||
// dispenseFault: the dispenser reported an error. The customer HAS
|
||||
// PAID and is owed the shortfall. The screen carries the txid, the
|
||||
// payment hash, the amounts and a statement that the operator has
|
||||
// been notified — this is lamassu's fiatTransactionError ("the
|
||||
// right prompt when they have paid and are owed money"), not the
|
||||
// out-of-cash screen a jam used to show.
|
||||
dispenseFault: {
|
||||
after: {
|
||||
DISPENSE_FAULT_TIMEOUT: '#atm.locked',
|
||||
},
|
||||
on: {
|
||||
ACKNOWLEDGE_FAULT: '#atm.locked',
|
||||
CANCEL: '#atm.locked',
|
||||
},
|
||||
},
|
||||
// outOfCash: the request could not be met and the dispenser
|
||||
// reported NO error — nothing was charged beyond what was dispensed.
|
||||
outOfCash: {
|
||||
after: {
|
||||
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,18 +21,48 @@ export interface CassetteBillResult {
|
|||
rejected: number
|
||||
}
|
||||
|
||||
/** Result of a dispense operation (always resolves, never throws) */
|
||||
/** How a dispense error should be routed — see @bitSpire/hal dispensers/error-codes.ts */
|
||||
export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory'
|
||||
|
||||
/** Result of a dispense operation (always resolves, never throws) — ADR-005 §3 */
|
||||
export interface DispenseCashResult {
|
||||
/** Per-denomination 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 */
|
||||
/**
|
||||
* Σ(denomination × dispensed) equals the requested fiat value. Computed by
|
||||
* the HAL on VALUE, never taken from a driver boolean. This is lamassu's
|
||||
* `dispenseConfirmed` and it is the only thing that routes to `complete`.
|
||||
*/
|
||||
dispenseConfirmed: boolean
|
||||
/** Human-readable message if dispense failed or was partial */
|
||||
error?: string
|
||||
/** The error's NAME, machine-readable — e.g. 'F56DispenseError' */
|
||||
errorCode?: string
|
||||
/** Driver-native code for the decode table — e.g. '78 42' */
|
||||
rawCode?: string
|
||||
/**
|
||||
* terminal → dispenseFault + latch cash-out; recoverable → dispenseFault;
|
||||
* inventory → outOfCash (nothing was asked of the hardware).
|
||||
*/
|
||||
errorClass?: DispenseErrorClass
|
||||
/** Per-cassette detail (position-aware, produced by HAL) */
|
||||
cassettes?: CassetteBillResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash-out is latched off after a terminal dispenser fault (ADR-005 §5).
|
||||
* Set by the machine when a fault lands; persisted and restored by the
|
||||
* store; cleared only by an operator `recount` or `resume_cash_out` op —
|
||||
* never by re-initialising the dispenser, which does not move a stuck note.
|
||||
*/
|
||||
export interface CashOutHold {
|
||||
reason: string
|
||||
errorCode: string | null
|
||||
rawCode: string | null
|
||||
/** unix seconds */
|
||||
since: number
|
||||
}
|
||||
|
||||
/** Payment methods supported */
|
||||
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
||||
|
||||
|
|
@ -122,6 +152,12 @@ export interface ATMContext {
|
|||
paymentStatus: PaymentStatus
|
||||
/** Payment preimage (proof of payment) */
|
||||
preimage: string | null
|
||||
/**
|
||||
* Payment hash of the settled invoice — the join key to the LNbits payment
|
||||
* the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a
|
||||
* customer who is owed money leaves with the reference the server indexes.
|
||||
*/
|
||||
paymentHash: string | null
|
||||
/** Payment method used */
|
||||
paymentMethod: PaymentMethod | null
|
||||
/** Pending offer request (Kind 21001 from user's wallet) */
|
||||
|
|
@ -157,6 +193,8 @@ export interface ATMContext {
|
|||
retryCount: number
|
||||
/** Result from the last dispense operation */
|
||||
dispenseResult: DispenseCashResult | null
|
||||
/** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */
|
||||
cashOutHeld: CashOutHold | null
|
||||
|
||||
// Transaction metadata
|
||||
/** Unique transaction ID */
|
||||
|
|
@ -199,8 +237,14 @@ export type ATMEvent =
|
|||
| { type: 'BILL_REJECTED'; reason: string }
|
||||
| { type: 'CASH_DISPENSED' }
|
||||
| { type: 'DISPENSE_ERROR'; error: string }
|
||||
// Customer acknowledges the fault screen ("I've saved this reference")
|
||||
| { type: 'ACKNOWLEDGE_FAULT' }
|
||||
// Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot
|
||||
// and releases it when an operator recount / resume_cash_out op lands.
|
||||
| { type: 'CASH_OUT_HELD'; hold: CashOutHold }
|
||||
| { type: 'CASH_OUT_RELEASED' }
|
||||
// Payment events
|
||||
| { type: 'PAYMENT_RECEIVED'; preimage: string }
|
||||
| { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string }
|
||||
| { type: 'PAYMENT_FAILED'; error: string }
|
||||
| { type: 'INVOICE_GENERATED'; invoice: string }
|
||||
| { type: 'OFFER_GENERATED'; offer: string }
|
||||
|
|
@ -245,6 +289,7 @@ export const initialContext: ATMContext = {
|
|||
lnurlWithdraw: null,
|
||||
paymentStatus: null,
|
||||
preimage: null,
|
||||
paymentHash: null,
|
||||
paymentMethod: null,
|
||||
pendingOfferRequest: null,
|
||||
billsInserted: [],
|
||||
|
|
@ -258,6 +303,7 @@ export const initialContext: ATMContext = {
|
|||
error: null,
|
||||
retryCount: 0,
|
||||
dispenseResult: null,
|
||||
cashOutHeld: null,
|
||||
txid: null,
|
||||
startedAt: null,
|
||||
cashInSessionId: null,
|
||||
|
|
@ -306,7 +352,10 @@ export interface ATMServices {
|
|||
* Watch an invoice for payment (polling-based)
|
||||
* Calls the callback when paid, returns cleanup function
|
||||
*/
|
||||
watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void
|
||||
watchInvoice: (
|
||||
invoice: string,
|
||||
callback: (preimage: string, paymentHash?: string) => void
|
||||
) => () => void
|
||||
/**
|
||||
* Get available inventory: denomination -> count
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue