fix(cash-out): settlement watch missed one-tap payments #99

Merged
padreug merged 2 commits from fix/cashout-settlement-race into dev 2026-09-22 16:29:36 +00:00
2 changed files with 47 additions and 0 deletions
Showing only changes of commit 67573008ee - Show all commits

fix(machine): surface a payment taken with no cash dispensed

When a card accepted a cash-out pull and settlement never confirmed, the
machine returned to the amount screen as though nothing had happened —
the customer's wallet had paid and there was nothing on screen or in the
journal to say so. Latch that transition, log it with the txid, and show
a red notice naming the reference an operator can reconcile against.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Padreug 2026-09-22 18:16:06 +02:00

View file

@ -343,6 +343,12 @@ export const useAtmStore = defineStore('atm', () => {
// The card balance is hidden by default on the public screen; the holder
// reveals it with the eye toggle. Resets on re-lock.
const cardBalanceRevealed = ref(false)
// Set when a card accepted a cash-out pull and the machine then left the
// invoice screen without ever seeing the payment settle. The customer's
// wallet paid and no cash came out, so this must be visible and carry the
// reference an operator can reconcile against — never a silent return to
// the amount screen. Cleared when the next transaction starts.
const settlementError = ref<{ txid: string | null; message: string } | null>(null)
// When the card server names a currency but didn't price the balance (its
// rate cache was cold — it never blocks the unlock on a rate lookup), price
// it here from the ATM's own rate source, in that currency.
@ -663,6 +669,26 @@ export const useAtmStore = defineStore('atm', () => {
(prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
if (leftBoltCardScreen) {
// The card accepted the pull (that's what leaves processing latched
// with an 'accepted' status) but we left the invoice screen for
// somewhere other than the dispenser: the payment was taken and no
// cash followed. Surface it with the txid instead of dropping the
// customer back on the amount screen as if nothing had happened.
if (
prevNestedState === 'displayingInvoice' &&
currentNested !== 'dispensingCash' &&
boltCardProcessing.value &&
nfcStatus.value?.state === 'accepted'
) {
const txid = context.value?.txid ?? null
console.error(
`[ATM] Settlement never confirmed after the card accepted the pull — txid=${txid}`
)
settlementError.value = {
txid,
message: 'Your payment was accepted but the cash was not dispensed.',
}
}
boltCardProcessing.value = false
nfcStatus.value = null
}
@ -1786,10 +1812,12 @@ export const useAtmStore = defineStore('atm', () => {
// Convenience methods for common events
function selectCashIn() {
settlementError.value = null
send({ type: 'SELECT_CASH_IN' })
}
function selectCashOut() {
settlementError.value = null
send({ type: 'SELECT_CASH_OUT' })
}
@ -1942,6 +1970,7 @@ export const useAtmStore = defineStore('atm', () => {
simulateBoltCardEntry,
// Access control (ADR-003)
settlementError,
accessControl,
isLocked,
grantAccess,

View file

@ -178,6 +178,24 @@ function formatFiat(cents: number): string {
key="selectingAmount"
class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6"
>
<!-- A payment was taken and no cash came out. Say so plainly, with
the reference an operator needs; do not let the customer wander
back into the amount grid believing nothing happened. -->
<div
v-if="atmStore.settlementError"
class="rounded-lg border-2 border-destructive bg-destructive/10 px-4 py-3 text-center lg:px-6 lg:py-4"
>
<p class="text-base font-bold text-destructive lg:text-2xl">
{{ atmStore.settlementError.message }}
</p>
<p class="mt-1 text-xs text-muted-foreground lg:text-lg">
Please contact the operator<template v-if="atmStore.settlementError.txid">
and quote
<span class="font-mono-code">{{ atmStore.settlementError.txid }}</span> </template
>.
</p>
</div>
<!-- Denomination grid -->
<div class="grid grid-cols-2 gap-3 lg:gap-6">
<div