fix(machine): credit bills on stacked-confirmation, not stack command (#58) #77

Merged
padreug merged 1 commit from fix/escrow-credit-interlock into dev 2026-07-25 20:44:12 +00:00
8 changed files with 294 additions and 24 deletions

View file

@ -36,6 +36,11 @@ export interface HalConfig {
export interface ValidatorCallbacks { export interface ValidatorCallbacks {
shouldAcceptBill: (denomination: number) => boolean | 'hold' shouldAcceptBill: (denomination: number) => boolean | 'hold'
onBillRead?: (denomination: number) => void onBillRead?: (denomination: number) => void
/**
* Fires on the validator's stacked-confirmation (`billsValid`) — the
* bill physically reached the stacker. This is the CREDIT event; it is
* NOT emitted at stack-command time (a stack can still fail/return).
*/
onBillInserted: (denomination: number) => void onBillInserted: (denomination: number) => void
onBillRejected: (reason: string) => void onBillRejected: (reason: string) => void
onError: (error: string) => void onError: (error: string) => void
@ -142,6 +147,14 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0, count: c.count ?? 0,
})) }))
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
// decision; `inFlightDenomination` = stack commanded, awaiting the
// validator's `billsValid` stacked-confirmation. onBillInserted (the
// credit event) fires only on that confirmation.
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
return { return {
connectValidator: (callbacks: ValidatorCallbacks) => { connectValidator: (callbacks: ValidatorCallbacks) => {
if (!validator) { if (!validator) {
@ -153,10 +166,11 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
const decision = callbacks.shouldAcceptBill(data.denomination) const decision = callbacks.shouldAcceptBill(data.denomination)
if (decision === 'hold') { if (decision === 'hold') {
console.log('[HAL] Bill in escrow:', data.denomination) console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination) callbacks.onBillRead?.(data.denomination)
} else if (decision) { } else if (decision) {
inFlightDenomination = data.denomination
validator.stack() validator.stack()
callbacks.onBillInserted(data.denomination)
} else { } else {
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination) console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
validator.reject() validator.reject()
@ -168,7 +182,23 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
} }
}) })
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
// Covers both an escrow refusal and a failed/returned stack —
// either way nothing was credited and nothing is in flight.
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown') callbacks.onBillRejected(data?.reason ?? 'unknown')
}) })
@ -195,8 +225,19 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
validator?.lightOff() validator?.lightOff()
}, },
stackBill: () => validator?.stack(), stackBill: () => {
rejectBill: () => validator?.reject(), if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator?.stack()
},
rejectBill: () => {
escrowDenomination = null
validator?.reject()
},
dispenseCash: async (amounts): Promise<DispenseResult> => { dispenseCash: async (amounts): Promise<DispenseResult> => {
console.log('[HAL] Dispensing:', amounts) console.log('[HAL] Dispensing:', amounts)

View file

@ -585,10 +585,12 @@ ipcMain.handle('hal:stack-bill', () => {
console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring') console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring')
return return
} }
const denomination = pendingBillDenomination
pendingBillDenomination = null pendingBillDenomination = null
// Credit is NOT sent here. hal-service fires onBillInserted (forwarded
// as 'hal:bill-inserted') only on the validator's `billsValid`
// stacked-confirmation — a stack command can still fail or return the
// bill (aiolabs/bitspire#58).
halInstance.stackBill() halInstance.stackBill()
mainWindow?.webContents.send('hal:bill-inserted', denomination)
}) })
ipcMain.handle('hal:reject-bill', () => { ipcMain.handle('hal:reject-bill', () => {

View file

@ -109,6 +109,12 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
}) })
console.log('[HAL] Validator started') console.log('[HAL] Validator started')
// Escrow / in-flight bookkeeping: credit (onBillInserted) fires only on
// the validator's `billsValid` stacked-confirmation, never at
// stack-command time (mirrors electron/hal-service.ts).
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
// Track inventory (decremented on dispense) // Track inventory (decremented on dispense)
const inventory: Record<number, number> = {} const inventory: Record<number, number> = {}
for (const cassette of dispConfig.cassettes) { for (const cassette of dispConfig.cassettes) {
@ -206,10 +212,13 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
if (decision === 'hold') { if (decision === 'hold') {
// Hold in escrow — caller will call stackBill() or rejectBill() // Hold in escrow — caller will call stackBill() or rejectBill()
console.log('[HAL] Bill in escrow:', data.denomination) console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination) callbacks.onBillRead?.(data.denomination)
} else if (decision) { } else if (decision) {
// Credit waits for the validator's stacked-confirmation
// (`billsValid`) — see the handler below.
inFlightDenomination = data.denomination
validator.stack() validator.stack()
callbacks.onBillInserted(data.denomination)
} else { } else {
console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination) console.log('[HAL] Bill rejected: insufficient ATM balance for', data.denomination)
validator.reject() validator.reject()
@ -221,7 +230,21 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
} }
}) })
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown') callbacks.onBillRejected(data?.reason ?? 'unknown')
}) })
@ -248,8 +271,19 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
validator.lightOff() validator.lightOff()
}, },
stackBill: () => validator.stack(), stackBill: () => {
rejectBill: () => validator.reject(), if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator.stack()
},
rejectBill: () => {
escrowDenomination = null
validator.reject()
},
cleanup: async () => { cleanup: async () => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {

View file

@ -949,11 +949,20 @@ export const useAtmStore = defineStore('atm', () => {
// Wire validator events to state machine // Wire validator events to state machine
hal.connectValidator({ hal.connectValidator({
shouldAcceptBill: (denomination) => { shouldAcceptBill: (denomination) => {
// Check if accepting this bill would exceed available balance
const ctx = context.value const ctx = context.value
if (!ctx || ctx.exchangeRate === 0) {
console.warn('[ATM] Cannot check balance: no exchange rate') // Fail closed: no rate/balance, or not in the accepting state →
return true // Allow if we don't have rate yet (shouldn't happen) // return the bill (legacy _billsRead parity).
if (
nestedState.value !== 'insertingBills' ||
!ctx ||
ctx.exchangeRate <= 0 ||
ctx.availableBalance <= 0
) {
console.log(
`[ATM] Rejecting $${denomination} bill: not accepting (state/rate/balance unknown)`
)
return false
} }
// Calculate what the new sats amount would be // Calculate what the new sats amount would be
@ -971,6 +980,9 @@ export const useAtmStore = defineStore('atm', () => {
return false return false
} }
// Accepting: mark the bill in flight. The HAL service issues the
// stack command; BILL_INSERTED follows on stacked-confirmation.
send({ type: 'BILL_PENDING', denomination })
return true return true
}, },
onBillInserted: (denomination) => { onBillInserted: (denomination) => {
@ -1249,11 +1261,22 @@ export const useAtmStore = defineStore('atm', () => {
// Wire validator events from main process via IPC // Wire validator events from main process via IPC
api.onHalBillRead((denomination) => { api.onHalBillRead((denomination) => {
console.log('[ATM] Bill in escrow:', denomination) console.log('[ATM] Bill in escrow:', denomination)
// Check if we should accept this bill
const ctx = context.value const ctx = context.value
if (!ctx || ctx.exchangeRate === 0) {
// No rate yet, accept anyway // Fail closed (legacy _billsRead parity): only stack while the
api.halStackBill() // machine is accepting bills AND rate + balance are known.
// Anything else returns the bill to the customer — stacking here
// would swallow cash the machine can't (or won't) credit.
if (
nestedState.value !== 'insertingBills' ||
!ctx ||
ctx.exchangeRate <= 0 ||
ctx.availableBalance <= 0
) {
console.log(
`[ATM] Rejecting $${denomination} bill: not accepting (state=${nestedState.value}, rate=${ctx?.exchangeRate ?? 'n/a'}, balance=${ctx?.availableBalance ?? 'n/a'})`
)
api.halRejectBill()
return return
} }
@ -1272,7 +1295,10 @@ export const useAtmStore = defineStore('atm', () => {
return return
} }
// Accept the bill // Accept the bill: mark it in flight, then command the stack.
// Credit (BILL_INSERTED) arrives via onHalBillInserted once the
// validator confirms the bill reached the stacker.
send({ type: 'BILL_PENDING', denomination })
api.halStackBill() api.halStackBill()
}) })
@ -1366,6 +1392,11 @@ export const useAtmStore = defineStore('atm', () => {
} }
function insertBill(denomination: number) { function insertBill(denomination: number) {
// Dev simulator: a real validator goes escrow → stack command →
// stacked-confirmation. Emit both halves so the simulated bill runs
// the same guarded path (BILL_PENDING is balance-gated; a refused
// pending drops the credit too).
send({ type: 'BILL_PENDING', denomination })
send({ type: 'BILL_INSERTED', denomination }) send({ type: 'BILL_INSERTED', denomination })
} }

View file

@ -242,7 +242,10 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</p> </p>
<!-- Status --> <!-- Status -->
<p v-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground"> <p v-if="context?.billPending" class="text-sm lg:text-xl text-muted-foreground">
⏳ Processing bill…
</p>
<p v-else-if="balanceLimitReached" class="text-sm lg:text-xl text-muted-foreground">
Maximum amount reached — press Done to continue Maximum amount reached — press Done to continue
</p> </p>
<p v-else class="text-sm lg:text-xl text-muted-foreground"> <p v-else class="text-sm lg:text-xl text-muted-foreground">
@ -269,11 +272,14 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<!-- Done button --> <!-- Done button — also blocked while a bill is between the
stack command and the validator's stacked-confirmation
(the machine guard drops FINISH_INSERTING regardless;
this keeps the UI honest about it) -->
<Button <Button
class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500" class="w-full bg-gradient-to-r from-orange-500 to-yellow-400 text-black hover:from-orange-600 hover:to-yellow-500"
size="kiosk-lg" size="kiosk-lg"
:disabled="!context || context.billsInserted.length === 0" :disabled="!context || context.billsInserted.length === 0 || context.billPending !== null"
@click="finishInserting" @click="finishInserting"
> >
Done Inserting Done Inserting

View file

@ -130,12 +130,17 @@ describe('ATM State Machine', () => {
// Wait for rate fetch // Wait for rate fetch
await new Promise((resolve) => setTimeout(resolve, 50)) await new Promise((resolve) => setTimeout(resolve, 50))
// Real hardware flow: escrow accepted (stack commanded) →
// stacked-confirmation credits the bill.
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 }) actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_PENDING', denomination: 10 })
actor.send({ type: 'BILL_INSERTED', denomination: 10 }) actor.send({ type: 'BILL_INSERTED', denomination: 10 })
const context = actor.getSnapshot().context const context = actor.getSnapshot().context
expect(context.billsInserted).toEqual([20, 10]) expect(context.billsInserted).toEqual([20, 10])
expect(context.fiatCents).toBe(3000) // $30 in cents expect(context.fiatCents).toBe(3000) // $30 in cents
expect(context.billPending).toBeNull()
}) })
it('should return to idle on CANCEL', async () => { it('should return to idle on CANCEL', async () => {
@ -152,6 +157,112 @@ describe('ATM State Machine', () => {
}) })
}) })
describe('cash-in escrow credit interlock (aiolabs/bitspire#58)', () => {
// Legacy brain.js parity: a bill is credited only on the validator's
// stacked-confirmation, and the insert phase cannot finish while a
// bill is between the stack command and that confirmation.
async function startInserting() {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(actor.getSnapshot().value).toEqual({ cashIn: 'insertingBills' })
return actor
}
it('blocks FINISH_INSERTING while a bill is in flight', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
// Second bill enters flight; user mashes "done" before it settles.
actor.send({ type: 'BILL_PENDING', denomination: 1 })
actor.send({ type: 'FINISH_INSERTING' })
// Still inserting — the in-flight bill holds the door.
const snap = actor.getSnapshot()
expect(snap.value).toEqual({ cashIn: 'insertingBills' })
expect(snap.context.billPending).toBe(1)
// Nothing was credited for the in-flight bill.
expect(snap.context.fiatCents).toBe(2000)
})
it('allows FINISH_INSERTING once the in-flight bill is confirmed', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 1 })
actor.send({ type: 'FINISH_INSERTING' }) // blocked
actor.send({ type: 'BILL_INSERTED', denomination: 1 }) // confirmation lands
actor.send({ type: 'FINISH_INSERTING' })
await new Promise((resolve) => setTimeout(resolve, 50))
const snap = actor.getSnapshot()
expect(snap.value).not.toEqual({ cashIn: 'insertingBills' })
expect(snap.context.fiatCents).toBe(100)
expect(snap.context.billPending).toBeNull()
})
it('BILL_REJECTED clears the in-flight bill without crediting', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_PENDING', denomination: 10 })
// Stack failed — validator returned the bill.
actor.send({ type: 'BILL_REJECTED', reason: 'returned' })
const snap = actor.getSnapshot()
expect(snap.context.billPending).toBeNull()
expect(snap.context.fiatCents).toBe(2000) // only the confirmed bill
expect(snap.context.billsInserted).toEqual([20])
// And the door is open again.
actor.send({ type: 'FINISH_INSERTING' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(actor.getSnapshot().value).not.toEqual({ cashIn: 'insertingBills' })
})
it('does not credit a BILL_INSERTED with no matching pending bill', async () => {
const actor = await startInserting()
// Stray stacked-confirmation (no stack was commanded).
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
const snap = actor.getSnapshot()
expect(snap.context.fiatCents).toBe(0)
expect(snap.context.billsInserted).toEqual([])
})
it('drops the credit too when BILL_PENDING was refused (over balance)', async () => {
const actor = await startInserting()
// Mock balance is 1M sats @ 2500 sats/USD → $400 ceiling. $500 bill
// exceeds it: pending is guard-refused, so its confirmation (which a
// correctly-driven validator would never send) is not credited either.
actor.send({ type: 'BILL_PENDING', denomination: 500 })
actor.send({ type: 'BILL_INSERTED', denomination: 500 })
const snap = actor.getSnapshot()
expect(snap.context.billPending).toBeNull()
expect(snap.context.fiatCents).toBe(0)
})
it('CANCEL with a bill in flight lands in confirmAbandon with pending cleared', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_PENDING', denomination: 10 })
actor.send({ type: 'CANCEL' })
const snap = actor.getSnapshot()
expect(snap.value).toEqual({ cashIn: 'confirmAbandon' })
expect(snap.context.billPending).toBeNull()
})
})
describe('cash-out flow (ATM-driven amount selection)', () => { describe('cash-out flow (ATM-driven amount selection)', () => {
it('should transition to cashOut on SELECT_CASH_OUT', async () => { it('should transition to cashOut on SELECT_CASH_OUT', async () => {
const machine = createATMMachine(mockServices) const machine = createATMMachine(mockServices)

View file

@ -181,6 +181,15 @@ export function createATMMachine(
setCashOutFee: assign({ setCashOutFee: assign({
feeFraction: ({ context }) => context.cashOutFeeFraction, feeFraction: ({ context }) => context.cashOutFeeFraction,
}), }),
setBillPending: assign({
billPending: ({ context, event }) => {
if (event.type !== 'BILL_PENDING') return context.billPending
return event.denomination
},
}),
clearBillPending: assign({
billPending: null,
}),
addBill: assign({ addBill: assign({
billsInserted: ({ context, event }) => { billsInserted: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return context.billsInserted if (event.type !== 'BILL_INSERTED') return context.billsInserted
@ -190,6 +199,7 @@ export function createATMMachine(
if (event.type !== 'BILL_INSERTED') return context.fiatCents if (event.type !== 'BILL_INSERTED') return context.fiatCents
return context.fiatCents + event.denomination * 100 return context.fiatCents + event.denomination * 100
}, },
billPending: null,
}), }),
calculateSats: assign({ calculateSats: assign({
satsAmount: ({ context }) => { satsAmount: ({ context }) => {
@ -367,13 +377,26 @@ export function createATMMachine(
}, },
guards: { guards: {
hasInsertedBills: ({ context }) => context.billsInserted.length > 0, hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
// Legacy brain.js parity: "send coins" is a no-op while a bill is
// between the stack command and the validator's stacked-confirmation.
canFinishInserting: ({ context }) =>
context.billsInserted.length > 0 && context.billPending === null,
// A credit event must correspond to the bill we commanded to stack —
// a stray stacked-confirmation with no pending bill is not credited.
billMatchesPending: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return false
return context.billPending === event.denomination
},
hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum
hasExchangeRate: ({ context }) => context.exchangeRate > 0, hasExchangeRate: ({ context }) => context.exchangeRate > 0,
canRetry: ({ context }) => context.retryCount < 3, canRetry: ({ context }) => context.retryCount < 3,
hasUserNpub: ({ context }) => context.userNpub !== null, hasUserNpub: ({ context }) => context.userNpub !== null,
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null, hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
// Gate at the PENDING (pre-stack) decision, matching legacy
// _billsRead: balance/rate checks happen before the bill is
// physically committed. Once stacked, credit is unconditional.
billWithinBalance: ({ context, event }) => { billWithinBalance: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return false if (event.type !== 'BILL_PENDING') return false
// Reject bills if balance or rate is unknown — don't risk accepting // Reject bills if balance or rate is unknown — don't risk accepting
// cash we can't cover with sats // cash we can't cover with sats
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
@ -476,15 +499,25 @@ export function createATMMachine(
], ],
}, },
on: { on: {
BILL_INSERTED: { // Escrow accepted → stack commanded. Marks the bill in
// flight; credit waits for the stacked-confirmation.
BILL_PENDING: {
guard: 'billWithinBalance', guard: 'billWithinBalance',
actions: 'setBillPending',
},
// Validator confirmed the bill reached the stacker. The
// cash is physically in the box — credit unconditionally.
BILL_INSERTED: {
guard: 'billMatchesPending',
actions: ['addBill', 'calculateSats'], actions: ['addBill', 'calculateSats'],
}, },
BILL_REJECTED: { BILL_REJECTED: {
// Stay in state, maybe show message // Bill returned to customer (stack failed or refused) —
// it was never credited; just clear the in-flight marker.
actions: 'clearBillPending',
}, },
FINISH_INSERTING: { FINISH_INSERTING: {
guard: 'hasInsertedBills', guard: 'canFinishInserting',
target: 'generatingNdebit', target: 'generatingNdebit',
}, },
CANCEL: [ CANCEL: [
@ -549,6 +582,9 @@ export function createATMMachine(
confirmAbandon: { confirmAbandon: {
// Warning: cash is in the machine, abandoning forfeits it. // Warning: cash is in the machine, abandoning forfeits it.
// Auto-clear after 60s so the machine self-recovers if customer walked away. // Auto-clear after 60s so the machine self-recovers if customer walked away.
// A bill still in flight when the user bails is forfeited
// like the rest — clear the marker so nothing blocks on it.
entry: 'clearBillPending',
after: { after: {
60000: '#atm.idle', 60000: '#atm.idle',
}, },

View file

@ -91,6 +91,13 @@ export interface ATMContext {
// Hardware state // Hardware state
/** Bills inserted during cash-in */ /** Bills inserted during cash-in */
billsInserted: number[] billsInserted: number[]
/**
* Bill in flight: stack commanded, awaiting the validator's
* stacked-confirmation. Credit (BILL_INSERTED) only lands once the
* bill physically reached the stacker; FINISH_INSERTING is blocked
* while a bill is in flight (legacy brain.js 'billsRead' interlock).
*/
billPending: number | null
/** Whether cash has been dispensed */ /** Whether cash has been dispensed */
cashDispensed: boolean cashDispensed: boolean
/** Dispense amounts for cash-out */ /** Dispense amounts for cash-out */
@ -140,6 +147,7 @@ export type ATMEvent =
| { type: 'CLEAR_SELECTION' } | { type: 'CLEAR_SELECTION' }
| { type: 'CONFIRM_AMOUNT' } | { type: 'CONFIRM_AMOUNT' }
// Hardware events // Hardware events
| { type: 'BILL_PENDING'; denomination: number }
| { type: 'BILL_INSERTED'; denomination: number } | { type: 'BILL_INSERTED'; denomination: number }
| { type: 'BILL_REJECTED'; reason: string } | { type: 'BILL_REJECTED'; reason: string }
| { type: 'CASH_DISPENSED' } | { type: 'CASH_DISPENSED' }
@ -187,6 +195,7 @@ export const initialContext: ATMContext = {
paymentMethod: null, paymentMethod: null,
pendingOfferRequest: null, pendingOfferRequest: null,
billsInserted: [], billsInserted: [],
billPending: null,
cashDispensed: false, cashDispensed: false,
dispenseAmounts: [], dispenseAmounts: [],
cashOutSelection: [], cashOutSelection: [],