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
935e34c916
commit
44c65c66c2
3 changed files with 347 additions and 115 deletions
|
|
@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
|
||||||
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
dispenseCash: vi.fn().mockResolvedValue({
|
||||||
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
||||||
dispensed: true,
|
dispenseConfirmed: true,
|
||||||
} satisfies DispenseCashResult),
|
} satisfies DispenseCashResult),
|
||||||
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
||||||
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
|
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
|
||||||
|
|
@ -397,127 +397,224 @@ describe('ATM State Machine', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispense error handling', () => {
|
describe('dispense outcome (ADR-005 §3–§5)', () => {
|
||||||
it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => {
|
/** Drive a 1 × $20 cash-out to the dispense and return the actor. */
|
||||||
const machine = createATMMachine(mockServices)
|
async function dispenseWith(result: DispenseCashResult, fake = false) {
|
||||||
const actor = createActor(machine)
|
const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) }
|
||||||
|
const actor = createActor(createATMMachine(services))
|
||||||
actor.start()
|
actor.start()
|
||||||
|
const tick = async (ms: number) =>
|
||||||
|
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
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: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await tick(100)
|
||||||
|
|
||||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
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()
|
const state = actor.getSnapshot()
|
||||||
// dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken
|
|
||||||
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
|
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
|
||||||
expect(state.context.cashDispensed).toBe(true)
|
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 () => {
|
it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => {
|
||||||
const failDispenseServices: ATMServices = {
|
const actor = await dispenseWith({
|
||||||
...mockServices,
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
dispenseConfirmed: false,
|
||||||
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
|
error: 'Note stopped at the cassette exit',
|
||||||
dispensed: false,
|
errorCode: 'F56DispenseError',
|
||||||
error: 'Cassette jam',
|
rawCode: '78 42',
|
||||||
} satisfies DispenseCashResult),
|
errorClass: 'terminal',
|
||||||
}
|
})
|
||||||
|
|
||||||
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()
|
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.cashDispensed).toBe(false)
|
||||||
expect(state.context.dispenseResult?.dispensed).toBe(false)
|
expect(state.context.error).toBe('Note stopped at the cassette exit')
|
||||||
expect(state.context.dispenseResult?.error).toBe('Cassette jam')
|
expect(state.context.dispenseResult?.rawCode).toBe('78 42')
|
||||||
expect(state.context.error).toBe('Cassette jam')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should auto-idle after 30s in dispenseError state', async () => {
|
it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => {
|
||||||
vi.useFakeTimers()
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
const failDispenseServices: ATMServices = {
|
dispenseConfirmed: false,
|
||||||
...mockServices,
|
error: 'jam',
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
errorCode: 'F56DispenseError',
|
||||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
rawCode: '78 42',
|
||||||
dispensed: false,
|
errorClass: 'terminal',
|
||||||
error: 'Out of cash',
|
})
|
||||||
} satisfies DispenseCashResult),
|
const hold = actor.getSnapshot().context.cashOutHeld
|
||||||
}
|
expect(hold).not.toBeNull()
|
||||||
|
expect(hold?.errorCode).toBe('F56DispenseError')
|
||||||
const machine = createATMMachine(failDispenseServices)
|
expect(hold?.rawCode).toBe('78 42')
|
||||||
const actor = createActor(machine)
|
expect(typeof hold?.since).toBe('number')
|
||||||
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' })
|
actor.send({ type: 'CANCEL' })
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
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 ATMContext,
|
||||||
type ATMEvent,
|
type ATMEvent,
|
||||||
type DispenseCashResult,
|
type DispenseCashResult,
|
||||||
|
type CashOutHold,
|
||||||
initialContext,
|
initialContext,
|
||||||
type ATMServices,
|
type ATMServices,
|
||||||
type OfferRequestEvent,
|
type OfferRequestEvent,
|
||||||
|
|
@ -148,8 +149,8 @@ export function createATMMachine(
|
||||||
}
|
}
|
||||||
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
|
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
|
||||||
// The service handles the actual extraction
|
// The service handles the actual extraction
|
||||||
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => {
|
const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => {
|
||||||
sendBack({ type: 'PAYMENT_RECEIVED', preimage })
|
sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash })
|
||||||
})
|
})
|
||||||
return cleanup
|
return cleanup
|
||||||
}),
|
}),
|
||||||
|
|
@ -184,6 +185,9 @@ export function createATMMachine(
|
||||||
// without this the just-granted session would be wiped. The session is
|
// 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.
|
// cleared instead on re-lock (locked's entry), i.e. when access ends.
|
||||||
accessSession: context.accessSession,
|
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,
|
cashInSessionId: null,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
})),
|
})),
|
||||||
|
|
@ -315,6 +319,10 @@ export function createATMMachine(
|
||||||
if (event.type !== 'PAYMENT_RECEIVED') return null
|
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||||
return event.preimage
|
return event.preimage
|
||||||
},
|
},
|
||||||
|
paymentHash: ({ event }) => {
|
||||||
|
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||||
|
return event.paymentHash ?? null
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
setPaymentFailed: assign({
|
setPaymentFailed: assign({
|
||||||
paymentStatus: () => 'failed' as const,
|
paymentStatus: () => 'failed' as const,
|
||||||
|
|
@ -332,6 +340,37 @@ export function createATMMachine(
|
||||||
return output?.error ?? null
|
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({
|
setAmount: assign({
|
||||||
fiatCents: ({ event }) => {
|
fiatCents: ({ event }) => {
|
||||||
if (event.type !== 'SELECT_AMOUNT') return 0
|
if (event.type !== 'SELECT_AMOUNT') return 0
|
||||||
|
|
@ -458,6 +497,18 @@ export function createATMMachine(
|
||||||
return principalSats - fee <= context.availableBalance
|
return principalSats - fee <= context.availableBalance
|
||||||
},
|
},
|
||||||
// Cash-out guards
|
// 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,
|
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
|
||||||
canAddDenomination: ({ context, event }) => {
|
canAddDenomination: ({ context, event }) => {
|
||||||
if (event.type !== 'ADD_DENOMINATION') return false
|
if (event.type !== 'ADD_DENOMINATION') return false
|
||||||
|
|
@ -477,7 +528,10 @@ export function createATMMachine(
|
||||||
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
|
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
|
||||||
COMPLETE_DELAY: 60000,
|
COMPLETE_DELAY: 60000,
|
||||||
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
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
|
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
|
||||||
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
||||||
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
||||||
|
|
@ -513,6 +567,11 @@ export function createATMMachine(
|
||||||
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
|
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: {
|
states: {
|
||||||
// === ACCESS GATE (ADR-003) ===
|
// === ACCESS GATE (ADR-003) ===
|
||||||
|
|
@ -557,6 +616,7 @@ export function createATMMachine(
|
||||||
actions: ['setStartTime', 'setCashInFee'],
|
actions: ['setStartTime', 'setCashInFee'],
|
||||||
},
|
},
|
||||||
SELECT_CASH_OUT: {
|
SELECT_CASH_OUT: {
|
||||||
|
guard: 'cashOutAvailable',
|
||||||
target: 'cashOut',
|
target: 'cashOut',
|
||||||
actions: ['setStartTime', 'setCashOutFee'],
|
actions: ['setStartTime', 'setCashOutFee'],
|
||||||
},
|
},
|
||||||
|
|
@ -861,13 +921,12 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
dispensingCash: {
|
dispensingCash: {
|
||||||
// Safety timeout: if dispenseCash promise hangs (hardware jam,
|
// 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: {
|
after: {
|
||||||
DISPENSE_TIMEOUT: {
|
DISPENSE_TIMEOUT: {
|
||||||
target: 'dispenseError',
|
target: 'dispenseFault',
|
||||||
actions: assign({
|
actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'],
|
||||||
error: () => 'Dispense timed out — hardware may be jammed',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invoke: {
|
invoke: {
|
||||||
|
|
@ -875,21 +934,31 @@ export function createATMMachine(
|
||||||
input: ({ context }) => context.dispenseAmounts,
|
input: ({ context }) => context.dispenseAmounts,
|
||||||
onDone: [
|
onDone: [
|
||||||
{
|
{
|
||||||
guard: ({ event }) =>
|
// ADR-005 §3: value equality, nothing else, completes.
|
||||||
(event.output as unknown as DispenseCashResult | undefined)?.dispensed === true,
|
guard: 'dispenseConfirmed',
|
||||||
target: 'waitingForCashTaken',
|
target: 'waitingForCashTaken',
|
||||||
actions: ['setCashDispensed', 'setDispenseResult'],
|
actions: ['setCashDispensed', 'setDispenseResult'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Partial or failed dispense
|
// ADR-005 §4: a hardware error means the customer has paid
|
||||||
target: 'dispenseError',
|
// 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',
|
actions: 'setDispenseResult',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onError: {
|
onError: {
|
||||||
// Unexpected crash (not a dispense failure)
|
// The service threw (not a reported dispense failure). No
|
||||||
target: 'dispenseError',
|
// per-bay report exists — the store flags counts unverified.
|
||||||
actions: 'setError',
|
target: 'dispenseFault',
|
||||||
|
actions: ['setError', 'latchCashOutIfTerminal'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -930,9 +999,26 @@ export function createATMMachine(
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispenseError: {
|
// ADR-005 §4 — two terminal states replace the old dispenseError.
|
||||||
// Payment received but cash not (fully) dispensed.
|
//
|
||||||
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
// 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: {
|
after: {
|
||||||
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,48 @@ export interface CassetteBillResult {
|
||||||
rejected: number
|
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 {
|
export interface DispenseCashResult {
|
||||||
/** Per-denomination results (what was actually dispensed) */
|
/** Per-denomination results (what was actually dispensed) */
|
||||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||||
/** Whether the full requested amount was dispensed */
|
/**
|
||||||
dispensed: boolean
|
* Σ(denomination × dispensed) equals the requested fiat value. Computed by
|
||||||
/** Error message if dispense failed or was partial */
|
* 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
|
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) */
|
/** Per-cassette detail (position-aware, produced by HAL) */
|
||||||
cassettes?: CassetteBillResult[]
|
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 */
|
/** Payment methods supported */
|
||||||
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
||||||
|
|
||||||
|
|
@ -122,6 +152,12 @@ export interface ATMContext {
|
||||||
paymentStatus: PaymentStatus
|
paymentStatus: PaymentStatus
|
||||||
/** Payment preimage (proof of payment) */
|
/** Payment preimage (proof of payment) */
|
||||||
preimage: string | null
|
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 */
|
/** Payment method used */
|
||||||
paymentMethod: PaymentMethod | null
|
paymentMethod: PaymentMethod | null
|
||||||
/** Pending offer request (Kind 21001 from user's wallet) */
|
/** Pending offer request (Kind 21001 from user's wallet) */
|
||||||
|
|
@ -157,6 +193,8 @@ export interface ATMContext {
|
||||||
retryCount: number
|
retryCount: number
|
||||||
/** Result from the last dispense operation */
|
/** Result from the last dispense operation */
|
||||||
dispenseResult: DispenseCashResult | null
|
dispenseResult: DispenseCashResult | null
|
||||||
|
/** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */
|
||||||
|
cashOutHeld: CashOutHold | null
|
||||||
|
|
||||||
// Transaction metadata
|
// Transaction metadata
|
||||||
/** Unique transaction ID */
|
/** Unique transaction ID */
|
||||||
|
|
@ -199,8 +237,14 @@ export type ATMEvent =
|
||||||
| { type: 'BILL_REJECTED'; reason: string }
|
| { type: 'BILL_REJECTED'; reason: string }
|
||||||
| { type: 'CASH_DISPENSED' }
|
| { type: 'CASH_DISPENSED' }
|
||||||
| { type: 'DISPENSE_ERROR'; error: string }
|
| { 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
|
// Payment events
|
||||||
| { type: 'PAYMENT_RECEIVED'; preimage: string }
|
| { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string }
|
||||||
| { type: 'PAYMENT_FAILED'; error: string }
|
| { type: 'PAYMENT_FAILED'; error: string }
|
||||||
| { type: 'INVOICE_GENERATED'; invoice: string }
|
| { type: 'INVOICE_GENERATED'; invoice: string }
|
||||||
| { type: 'OFFER_GENERATED'; offer: string }
|
| { type: 'OFFER_GENERATED'; offer: string }
|
||||||
|
|
@ -245,6 +289,7 @@ export const initialContext: ATMContext = {
|
||||||
lnurlWithdraw: null,
|
lnurlWithdraw: null,
|
||||||
paymentStatus: null,
|
paymentStatus: null,
|
||||||
preimage: null,
|
preimage: null,
|
||||||
|
paymentHash: null,
|
||||||
paymentMethod: null,
|
paymentMethod: null,
|
||||||
pendingOfferRequest: null,
|
pendingOfferRequest: null,
|
||||||
billsInserted: [],
|
billsInserted: [],
|
||||||
|
|
@ -258,6 +303,7 @@ export const initialContext: ATMContext = {
|
||||||
error: null,
|
error: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
|
cashOutHeld: null,
|
||||||
txid: null,
|
txid: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
|
|
@ -306,7 +352,10 @@ export interface ATMServices {
|
||||||
* Watch an invoice for payment (polling-based)
|
* Watch an invoice for payment (polling-based)
|
||||||
* Calls the callback when paid, returns cleanup function
|
* 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
|
* Get available inventory: denomination -> count
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue