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>
This commit is contained in:
parent
ebb07ce22d
commit
67573008ee
2 changed files with 47 additions and 0 deletions
|
|
@ -343,6 +343,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
// The card balance is hidden by default on the public screen; the holder
|
// The card balance is hidden by default on the public screen; the holder
|
||||||
// reveals it with the eye toggle. Resets on re-lock.
|
// reveals it with the eye toggle. Resets on re-lock.
|
||||||
const cardBalanceRevealed = ref(false)
|
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
|
// 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
|
// 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.
|
// 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 === 'displayingInvoice' && currentNested !== 'displayingInvoice') ||
|
||||||
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
|
(prevNestedState === 'displayingQR' && currentNested !== 'displayingQR')
|
||||||
if (leftBoltCardScreen) {
|
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
|
boltCardProcessing.value = false
|
||||||
nfcStatus.value = null
|
nfcStatus.value = null
|
||||||
}
|
}
|
||||||
|
|
@ -1786,10 +1812,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
// Convenience methods for common events
|
// Convenience methods for common events
|
||||||
function selectCashIn() {
|
function selectCashIn() {
|
||||||
|
settlementError.value = null
|
||||||
send({ type: 'SELECT_CASH_IN' })
|
send({ type: 'SELECT_CASH_IN' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCashOut() {
|
function selectCashOut() {
|
||||||
|
settlementError.value = null
|
||||||
send({ type: 'SELECT_CASH_OUT' })
|
send({ type: 'SELECT_CASH_OUT' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1942,6 +1970,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
simulateBoltCardEntry,
|
simulateBoltCardEntry,
|
||||||
|
|
||||||
// Access control (ADR-003)
|
// Access control (ADR-003)
|
||||||
|
settlementError,
|
||||||
accessControl,
|
accessControl,
|
||||||
isLocked,
|
isLocked,
|
||||||
grantAccess,
|
grantAccess,
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,24 @@ function formatFiat(cents: number): string {
|
||||||
key="selectingAmount"
|
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"
|
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 -->
|
<!-- Denomination grid -->
|
||||||
<div class="grid grid-cols-2 gap-3 lg:gap-6">
|
<div class="grid grid-cols-2 gap-3 lg:gap-6">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue