feat(machine): value-confirmed dispense, cash-out hold, fault screens, counts-uncertain on zero-with-error (ADR-005 §3–§5)

HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts):
dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination ×
requested), computed on value. The driver's tagged error is carried
through as errorCode / rawCode / errorClass / human; pre-dispense
inventory refusals are errorClass 'inventory' so they route to outOfCash
rather than the fault screen. The manual-dispense command result keeps
its wire key `dispensed` (spirekeeper's poller reads it) and gains the
new fields alongside.

Cash-out hold: state-store persists it in meta as one JSON value beside
countsUncertainSince, idempotent on set (the first fault's `since` is
kept); IPC get/set/clear through preload. The store persists the hold the
moment the machine sets it and restores it into the machine on boot. A
recount clears it in the store (same gesture that clears counts-
uncertain); operator-config also honours a new resume_cash_out op — not a
cassette op, split off before applyOperatorCassetteOps, and honoured only
when stamped after the hold began so a re-delivered old resume cannot
clear a fresh fault. Either release calls back into the store, which
sends CASH_OUT_RELEASED. The cassettes-state document carries
cash_out_held_since / _reason / _code (additive, like
counts_uncertain_since); the availability beacon reports cash_out false
while held; the idle Sell button is disabled with the reason.

Store watcher: dispenseFault and outOfCash both record dispense_error /
partial (the customer has paid either way). A report of zero dispensed
WITH a hardware error now sets countsUncertainSince instead of being
trusted as zero — a note stopped in the transport completes neither
counter (sintra 2026-10-09: bay read 66, held 65, one in the transport).

Fault screen: both terminal states show "your payment went through",
amount paid, per-denomination dispensed, the txid as QR and text, the
payment hash (threaded from the settlement watch through PAYMENT_RECEIVED)
and the time, with "keep this reference" and an acknowledge button. The
raw dispenser code is not shown; it travels in the report.
This commit is contained in:
Padreug 2026-10-10 21:25:51 +02:00
commit 888870d01a
12 changed files with 469 additions and 72 deletions

View file

@ -29,8 +29,14 @@ interface UseAvailabilityBroadcastOptions {
signer: Signer
/** Reactive inventory: denomination -> count */
inventory: Ref<Record<number, number>>
/** Reactive Lightning.Pub balance in sats (null = unknown) */
/** Reactive wallet balance in sats (null = unknown) */
balanceSats: Ref<number | null>
/**
* ADR-005 §5: cash-out is latched off after a terminal dispenser fault.
* A machine with full bays and a jammed transport must not advertise
* cash-out — that is exactly what sintra did for an hour on 2026-10-09.
*/
cashOutHeld?: Ref<boolean>
/** Fiat currency code */
fiatCode: string
/** Machine model */
@ -38,7 +44,7 @@ interface UseAvailabilityBroadcastOptions {
}
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options
const { nostrClient, signer, inventory, balanceSats, cashOutHeld, fiatCode, model } = options
let lastSnapshot: AvailabilitySnapshot | null = null
@ -53,7 +59,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
function computeSnapshot(): AvailabilitySnapshot {
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
return {
cashOut: totalBills > 0,
cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false),
cashIn: (balanceSats.value ?? 0) > 0,
cashLevel: computeCashLevel(),
}
@ -101,7 +107,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
// Watch reactive sources
watch(
[inventory, balanceSats],
cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats],
() => {
debouncedPublish()
},

View file

@ -147,8 +147,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
dispensed: 0,
rejected: 0,
})),
dispensed: false,
dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
}
}
notes[idx] = count
@ -182,11 +184,23 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
rejected: c.rejected,
}))
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
// ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts.
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) {
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
const e = result.error
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: e.human ?? e.message,
errorCode: e.errorCode ?? e.name,
rawCode: e.rawCode,
errorClass: e.errorClass ?? 'terminal',
}
}
// Wait for customer to take bills (only if bills were dispensed)
@ -195,7 +209,18 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
console.log('[HAL] Bills removed by customer')
}
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
if (!dispenseConfirmed) {
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
errorCode: 'DispenseShort',
errorClass: 'inventory',
}
}
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
},
getInventory: async () => {

View file

@ -742,7 +742,7 @@ export function createATMServices(
subId: string | null
/** Preimage seen before a consumer attached; replayed on attach. */
settled: string | null
consumer: ((preimage: string) => void) | null
consumer: ((preimage: string, paymentHash: string) => void) | null
poll: ReturnType<typeof setInterval> | null
released: boolean
}
@ -765,7 +765,7 @@ export function createATMServices(
watch.settled = preimage
stopInvoiceWatchPoll(watch)
console.log(`[ATM Service] Invoice paid (${via})!`)
watch.consumer?.(preimage)
watch.consumer?.(preimage, watch.paymentHash)
}
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
@ -1106,7 +1106,7 @@ export function createATMServices(
dispensed: a.count,
rejected: 0,
})),
dispensed: true,
dispenseConfirmed: true,
}
},
@ -1206,7 +1206,10 @@ export function createATMServices(
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments
* push, filtered by payment_hash. Returns a cleanup function.
*/
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
watchInvoice: (
invoice: string,
callback: (preimage: string, paymentHash?: string) => void
): (() => void) => {
if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
return () => {}
@ -1221,7 +1224,7 @@ export function createATMServices(
// on a push that has already come and gone.
if (armed.settled) {
const preimage = armed.settled
queueMicrotask(() => callback(preimage))
queueMicrotask(() => callback(preimage, armed.paymentHash))
}
return () => releaseInvoiceWatch(invoice)
}
@ -1244,7 +1247,7 @@ export function createATMServices(
const late = invoiceWatches.get(invoice)
if (!late || cancelled) return
late.consumer = callback
if (late.settled) callback(late.settled)
if (late.settled) callback(late.settled, late.paymentHash)
} catch (e) {
console.error('[ATM Service] LNbits watchInvoice failed:', e)
}

View file

@ -44,7 +44,7 @@ const KIND_NIP78 = 30078
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
const CASSETTE_SCHEMA_VERSION = 2
/** One operator-authored operation, as it arrives on the wire. */
/** One operator-authored cassette operation, as it arrives on the wire. */
type CassetteOp = {
id: string
at: number
@ -55,6 +55,18 @@ type CassetteOp = {
denomination?: number
}
/**
* ADR-005 §5: the operator releases a cash-out hold without touching a bay
* count. Rides the same operator event as the cassette ops (same id/at shape)
* but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which
* would reject the type. Honoured only when stamped AFTER the hold began, so
* a re-delivered resume from before a fresh fault cannot clear that fault.
* A `recount` releases the hold too — it is the same "operator at the open
* machine" gesture and already clears counts-uncertain.
*/
type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' }
type OperatorOp = CassetteOp | ResumeCashOutOp
/** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60
@ -85,6 +97,12 @@ export interface OperatorConfigServiceConfig {
operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string
/**
* ADR-005 §5: called when an operator op (recount, resume_cash_out) has
* released a persisted cash-out hold, so the store can lift the state
* machine's latch. The store wires this to `CASH_OUT_RELEASED`.
*/
onCashOutHoldReleased?: () => void
}
export interface OperatorConfigService {
@ -222,7 +240,28 @@ async function handleOperatorConfigEvent(
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
return
}
const ops = parsed.ops as CassetteOp[]
const allOps = parsed.ops as OperatorOp[]
// 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply.
// Release only if a resume is stamped after the hold began; an idempotent
// re-delivery of an older resume must not clear a newer fault.
const holdBefore = await api.getCashOutHold()
const resumeOps = allOps.filter(
(o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out'
)
const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out')
if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) {
await api.clearCashOutHold()
console.log(
`[OperatorConfig] Cash-out hold released by operator resume op ` +
`(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))`
)
} else if (resumeOps.length > 0) {
console.log(
`[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` +
(holdBefore ? 'all stamped before the current hold began' : 'no hold in place')
)
}
// 5. Apply the ones we have not seen, in one transaction with the sequence
// bump. No `created_at` watermark: each op carries an operator-minted id
@ -230,10 +269,19 @@ async function handleOperatorConfigEvent(
// no-op on its own merits. The watermark would be strictly weaker and
// actively harmful — an event arriving out of order can still carry an
// operation this machine has never seen.
const result = await api.applyOperatorCassetteOps(ops)
const result = ops.length
? await api.applyOperatorCassetteOps(ops)
: { applied: [] as string[], rejected: [] as { id: string; reason: string }[] }
for (const bad of result.rejected) {
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
}
// A recount (applied in the store, which also clears the hold) or the resume
// above may have released the latch: tell the store so the state machine
// lifts its guard. The republishes below carry the cleared state up.
if (holdBefore && (await api.getCashOutHold()) === null) {
cfg.onCashOutHoldReleased?.()
}
if (result.applied.length === 0) {
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
// Still republish: the operator learns from our applied_ops echo that
@ -318,6 +366,15 @@ async function publishCassettesState(
applied_ops: appliedOps,
}
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
// ADR-005 §5 — additive, same contract as counts_uncertain_since: an old
// consumer ignores it. When present, this machine is refusing cash-out
// until an operator recount or resume_cash_out op.
const hold = await api.getCashOutHold()
if (hold) {
payload.cash_out_held_since = hold.since
payload.cash_out_held_reason = hold.reason
payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null
}
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
// Force the stamp strictly above our last one. Addressable events are ordered

View file

@ -126,7 +126,7 @@ async function handleManagementCommand(
await persistTransaction({
txid,
type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error',
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
@ -141,7 +141,7 @@ async function handleManagementCommand(
// Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false
if (request.ref_txid && result.dispensed && isElectron && window.electronAPI) {
if (request.ref_txid && result.dispenseConfirmed && isElectron && window.electronAPI) {
refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
if (refRemediated) {
console.log('[ATM] Remediated failed tx:', request.ref_txid)
@ -254,7 +254,7 @@ const mockServices: ATMServices = {
dispensed: a.count,
rejected: 0,
})),
dispensed: true,
dispenseConfirmed: true,
}
},
@ -618,13 +618,31 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CASH_DISPENSED' })
}
// Record failed cash-out dispenses (sats debited but cash not dispensed)
if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') {
// ADR-005 §5: persist the cash-out hold the moment the machine sets it,
// and republish the cassette state so the operator sees it. The hold is
// machine health, not transaction state — it must survive a restart.
const heldNow = newSnapshot.context.cashOutHeld
const heldBefore = prevSnapshot?.context.cashOutHeld ?? null
if (heldNow && !heldBefore && isElectron && window.electronAPI) {
void window.electronAPI
.setCashOutHold(heldNow)
.then(() => operatorConfigSvc?.publishCassettesState())
.catch((e) => console.error('[ATM] Could not persist cash-out hold:', e))
}
// Record a cash-out that did not confirm (ADR-005 §3/§4). Both terminal
// states mean the customer has PAID and received less than they paid
// for — dispenseFault because the dispenser reported an error, outOfCash
// because it reported none (an inventory refusal, or simply short).
// Either way the row is dispense_error / partial and the server learns
// of it; the difference is only the customer screen and the latch.
const isDispenseTerminal = currentNested === 'dispenseFault' || currentNested === 'outOfCash'
const wasDispenseTerminal = prevNestedState === 'dispenseFault' || prevNestedState === 'outOfCash'
if (isDispenseTerminal && !wasDispenseTerminal) {
const ctx = newSnapshot.context
if (ctx.txid) {
const dr = ctx.dispenseResult
// Determine status from dispense result (if available)
let status: 'dispense_error' | 'partial' = 'dispense_error'
let bills: { denomination: number; count: number }[] = []
@ -634,6 +652,23 @@ export const useAtmStore = defineStore('atm', () => {
bills = dr.bills
.filter((b) => b.dispensed > 0)
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
// ADR-005 §3 — the deviation from both bitSpire-before and lamassu:
// a report of ZERO dispensed that arrives WITH a hardware error is
// not evidence that nothing left the bay. A note that stops in the
// transport path completes neither the dispensed nor the rejected
// counter (sintra, 2026-10-09: bay read 66, held 65, one in the
// transport). Flag the counts unverified so the next recount is
// what resolves them, instead of trusting a zero.
if (totalDispensed === 0 && dr.error && dr.errorClass !== 'inventory') {
console.error(
`[ATM] Dispense reported 0 notes WITH an error (${dr.errorCode ?? 'unknown'}` +
`${dr.rawCode ? ` ${dr.rawCode}` : ''}) — bay counts are unverified (txid=${ctx.txid})`
)
void window.electronAPI
?.markCountsUncertain(Math.floor(Date.now() / 1000))
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
}
} else {
// The dispenser threw, or the dispense timed out, so there is no
// per-bay report. Bills may well have reached the customer, and
@ -664,7 +699,8 @@ export const useAtmStore = defineStore('atm', () => {
error: dr?.error ?? ctx.error,
})
.then(() => reloadPersistedInventory())
// Republish cassette state — a partial dispense changed counts.
// Republish cassette state — a partial dispense changed counts, and
// the payload now carries the hold / unverified flags.
.then(() => operatorConfigSvc?.publishCassettesState())
}
}
@ -742,6 +778,23 @@ export const useAtmStore = defineStore('atm', () => {
setupNfcListener()
setupCassettesChangedListener()
console.log('[ATM] State machine initialized')
// ADR-005 §5: a cash-out hold persisted by a previous run gates cash-out
// before any dispense — a restart must not quietly put a jammed machine
// back in service. Released only by an operator recount / resume op.
if (isElectron && window.electronAPI) {
void window.electronAPI
.getCashOutHold()
.then((hold) => {
if (!hold) return
console.warn(
`[ATM] Cash-out HELD since ${new Date(hold.since * 1000).toISOString()} ` +
`(${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''}): ${hold.reason}`
)
send({ type: 'CASH_OUT_HELD', hold })
})
.catch((e) => console.warn('[ATM] Could not read cash-out hold:', e))
}
}
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
@ -1135,6 +1188,7 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: services.nostrClient,
signer: services.signer,
operatorPubkeys: services.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
})
// Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes
@ -1461,6 +1515,7 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: lightning.nostrClient,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
})
// Operator-fees consumer (aiolabs/lamassu-next#57)
@ -1797,6 +1852,7 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: lightning.nostrClient,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
})
// Operator-fees consumer (aiolabs/lamassu-next#57)
@ -1899,6 +1955,11 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'SELECT_CASH_IN' })
}
/** Customer dismisses the dispense-fault screen ("I've saved this reference"). */
function acknowledgeFault() {
send({ type: 'ACKNOWLEDGE_FAULT' })
}
function selectCashOut() {
settlementError.value = null
send({ type: 'SELECT_CASH_OUT' })
@ -2004,6 +2065,8 @@ export const useAtmStore = defineStore('atm', () => {
signer,
inventory: persistedInventory,
balanceSats,
// ADR-005 §5: a latched machine must not advertise cash-out.
cashOutHeld: computed(() => snapshot.value?.context.cashOutHeld != null),
fiatCode: fiatCode.value,
model,
})
@ -2069,6 +2132,7 @@ export const useAtmStore = defineStore('atm', () => {
send,
selectCashIn,
selectCashOut,
acknowledgeFault,
cancel,
insertBill,
finishInserting,

View file

@ -150,6 +150,20 @@ declare global {
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
// Cash-out hold (ADR-005 §5)
getCashOutHold: () => Promise<{
reason: string
errorCode: string | null
rawCode: string | null
since: number
} | null>
setCashOutHold: (hold: {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }>
clearCashOutHold: () => Promise<boolean>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>

View file

@ -63,13 +63,19 @@ watch(
const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context)
// Dispense error 30s countdown
// Terminal-screen countdowns (ADR-005 §4). The machine owns the real timers
// (DISPENSE_FAULT_TIMEOUT 120 s, DISPENSE_ERROR_TIMEOUT 30 s); this mirrors
// them for display only.
const TERMINAL_SECONDS: Record<string, number> = { dispenseFault: 120, outOfCash: 30 }
const dispenseErrorCountdown = ref(30)
let countdownTimer: ReturnType<typeof setInterval> | null = null
watch(nestedState, (newState, oldState) => {
if (newState === 'dispenseError' && oldState !== 'dispenseError') {
dispenseErrorCountdown.value = 30
const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS
const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS
if (entering && newState !== oldState) {
if (countdownTimer) clearInterval(countdownTimer)
dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30
countdownTimer = setInterval(() => {
dispenseErrorCountdown.value--
if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
@ -77,12 +83,21 @@ watch(nestedState, (newState, oldState) => {
countdownTimer = null
}
}, 1000)
} else if (oldState === 'dispenseError' && countdownTimer) {
} else if (leaving && !entering && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
function acknowledgeFault() {
atmStore.acknowledgeFault()
}
const faultTime = computed(() => {
const t = context.value?.startedAt
return t ? new Date(t).toLocaleString() : ''
})
// Available denominations from inventory
const availableDenominations = computed(() => {
if (!context.value?.inventory) return []
@ -535,63 +550,92 @@ function formatFiat(cents: number): string {
</div>
</div>
<!-- Dispense Error (timed, 30s → idle) -->
<!-- Dispense fault / could-not-dispense (ADR-005 §4).
Both reach here AFTER payment: the customer has paid and received
less than they paid for. dispenseFault = the dispenser reported an
error (and may have latched cash-out off); outOfCash = it reported
none. Either way: evidence on screen, operator notified. The raw
dispenser code is deliberately NOT shown — it travels in the report. -->
<div
v-else-if="nestedState === 'dispenseError'"
key="dispenseError"
v-else-if="nestedState === 'dispenseFault' || nestedState === 'outOfCash'"
key="dispenseTerminal"
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
>
<!-- Left side — error details -->
<div class="flex flex-col items-center gap-3 lg:gap-5">
<div class="flex flex-col items-center gap-3 lg:gap-5 max-w-xl">
<div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3>
<p class="text-base lg:text-2xl text-muted-foreground">
{{ context?.error || 'Cash could not be dispensed' }}
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">
{{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }}
</h3>
<p class="text-base lg:text-2xl text-foreground text-center font-semibold">
Your payment went through. The cash below could not be dispensed.
</p>
<!-- Partial dispense info -->
<div
v-if="context?.dispenseResult?.bills?.length"
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2"
>
<div class="w-full rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2">
<div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground">You paid</span>
<span class="font-semibold"
>{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }}
<span class="text-muted-foreground text-sm lg:text-lg"
>({{ (context?.satsAmount ?? 0).toLocaleString() }} sats)</span
></span
>
</div>
<div
v-for="bill in context.dispenseResult.bills"
v-for="bill in context?.dispenseResult?.bills ?? []"
:key="bill.denomination"
class="flex justify-between text-base lg:text-2xl"
>
<span class="text-muted-foreground"
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span
>{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes</span
>
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
{{ bill.dispensed }} dispensed
<span v-if="bill.rejected > 0" class="text-destructive">
({{ bill.rejected }} rejected)
</span>
</span>
</div>
</div>
<p class="text-sm lg:text-lg text-muted-foreground">
Please contact support with the transaction ID below.
<p class="text-base lg:text-xl text-foreground text-center">
The operator has been notified and holds a record of this transaction.
<strong>Keep this reference</strong> — photograph it or write it down.
</p>
<p v-if="context?.error" class="text-xs lg:text-sm text-muted-foreground text-center">
Technical detail: {{ context.error }}
</p>
<!-- Countdown -->
<div class="flex flex-wrap items-center justify-center gap-3">
<Button
v-if="nestedState === 'dispenseFault'"
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
size="kiosk"
@click="acknowledgeFault"
>
I've saved this
</Button>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div>
<p class="text-sm lg:text-base text-muted-foreground">
Returning to start in {{ dispenseErrorCountdown }}s
</p>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div>
<!-- Right side — txid QR -->
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
<QRCode :value="context.txid" :size="280" />
<div v-if="context?.txid" class="flex flex-col items-center gap-3">
<QRCode :value="context.txid" :size="260" />
<p class="text-xs lg:text-sm text-muted-foreground">Transaction</p>
<p
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
class="font-mono-code text-sm lg:text-base text-foreground max-w-[320px] text-center break-all"
>
{{ context.txid }}
</p>
<template v-if="context?.paymentHash">
<p class="text-xs lg:text-sm text-muted-foreground">Payment hash</p>
<p
class="font-mono-code text-xs lg:text-sm text-foreground max-w-[320px] text-center break-all"
>
{{ context.paymentHash }}
</p>
</template>
<p v-if="faultTime" class="text-xs lg:text-sm text-muted-foreground">{{ faultTime }}</p>
</div>
</div>

View file

@ -121,16 +121,32 @@ function handleCashOut() {
>
</button>
<!-- Sell Bitcoin -->
<!-- Sell Bitcoin — disabled while cash-out is held after a terminal
dispenser fault (ADR-005 §5). The state machine refuses
SELECT_CASH_OUT regardless; this just tells the customer why. -->
<button
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97]"
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 transition-all"
:class="
atmStore.context?.cashOutHeld
? 'border-muted-foreground/30 bg-muted/20 opacity-60 cursor-not-allowed'
: 'border-success/50 bg-success/10 active:scale-[0.97]'
"
:disabled="!!atmStore.context?.cashOutHeld"
@click="handleCashOut"
>
<span class="text-4xl lg:text-[8vh] leading-none">💵</span>
<span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span>
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70"
>Pay invoice, receive cash</span
<span class="text-4xl lg:text-[8vh] leading-none">{{
atmStore.context?.cashOutHeld ? '🔧' : '💵'
}}</span>
<span
class="text-base lg:text-[3.5vh] font-bold"
:class="atmStore.context?.cashOutHeld ? 'text-muted-foreground' : 'text-success'"
>Sell Bitcoin</span
>
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70 text-center px-3">{{
atmStore.context?.cashOutHeld
? 'Temporarily unavailable — operator notified'
: 'Pay invoice, receive cash'
}}</span>
</button>
</div>