fix(access): instrument Complete, and say when a card can't sell

Pressing Complete on a session that fails leaves nothing in the journal:
the decline path has no logging, so a failed sell is indistinguishable
from a button that never fired. Add the telemetry that was missing —
completeWithCard logs outcome, duration and reason, and the entry line
now says whether selling is available at all.

Two real defects alongside it. A session whose withdraw step the card
server withheld (daily limit spent, card disabled) still rendered a
Complete Sale button that could only ever fail; it now shows the
server's reason instead. And the decline path advised 'tap your card to
try again' even for refusals a re-tap cannot lift, so 'blocked' is now
its own outcome: nothing was consumed, the session stays loaded, and no
re-tap is suggested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-22 17:50:49 +02:00
commit 61d5bf0231
2 changed files with 46 additions and 9 deletions

View file

@ -301,7 +301,11 @@ export const useAtmStore = defineStore('atm', () => {
// 'declined' — presented and refused, or failed after presentation. The
// boltcards server bumps the SUN counter on the first GET, so
// treat the voucher as spent even when the failure was ours.
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined'
// 'blocked' — refused BEFORE anything was presented, because the card
// server withheld that step when the session opened (e.g. the
// card's daily limit is already spent). Nothing was consumed
// and no re-tap can lift it, so the session stays loaded.
type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' | 'blocked'
// Where a Complete gets its voucher: a card tapped right now on the cash
// screen (raw lnurlw, spent by this call) or the session opened at entry
// (hit-keyed steps, no p/c).
@ -715,6 +719,15 @@ export const useAtmStore = defineStore('atm', () => {
const invoice = context.value?.invoice
if (!invoice) return 'skipped'
if (boltCardProcessing.value) return 'skipped' // one pull at a time
// The card server withheld the withdraw step when this session opened, so
// there is nothing to present. Say why instead of attempting a payment.
if ('session' in source && !source.session.withdraw) {
nfcStatus.value = {
state: 'declined',
message: source.session.withdrawBlockedReason ?? 'This card cannot sell right now',
}
return 'blocked'
}
boltCardProcessing.value = true
nfcStatus.value = { state: 'processing', message: 'Reading card…' }
try {
@ -722,13 +735,12 @@ export const useAtmStore = defineStore('atm', () => {
const api = window.electronAPI!
const res =
'session' in source
? source.session.withdraw
? await api.withdrawWithSession({
withdraw: plainWithdrawStep(source.session.withdraw),
bolt11: invoice,
amountMsat,
})
: { ok: false, reason: source.session.withdrawBlockedReason ?? 'card cannot pay now' }
? await api.withdrawWithSession({
// Non-null: a withheld step is pre-flighted above.
withdraw: plainWithdrawStep(source.session.withdraw!),
bolt11: invoice,
amountMsat,
})
: await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat })
if (res.ok) {
nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' }
@ -819,7 +831,10 @@ export const useAtmStore = defineStore('atm', () => {
console.info(
`[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` +
(opened.ok
? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server)`
? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server; sell ` +
(opened.session.withdraw
? 'available)'
: `BLOCKED: ${opened.session.withdrawBlockedReason ?? 'withdraw step withheld'})`)
: '')
)
if (!opened.ok) {
@ -878,10 +893,19 @@ export const useAtmStore = defineStore('atm', () => {
async function completeWithCard() {
const card = loadedBoltCard.value
if (!card) return
const t0 = Date.now()
let outcome: BoltCardOutcome = 'skipped'
if (isCashOut.value) outcome = await handleBoltCardTap({ session: card })
else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card })
console.info(
`[ATM] Complete with card: ${outcome} in ${Date.now() - t0} ms` +
(outcome === 'accepted' ? '' : ` — ${nfcStatus.value?.message ?? 'no reason given'}`)
)
if (outcome === 'skipped') return
// Blocked is the card server's standing answer for this visit: nothing was
// consumed, and re-tapping cannot lift it. Keep the session loaded so the
// chip still shows whose card it is, and skip the re-tap advice below.
if (outcome === 'blocked') return
loadedBoltCard.value = null
if (outcome === 'declined') {
const reason = nfcStatus.value?.message ?? 'Card declined'

View file

@ -353,7 +353,20 @@ function formatFiat(cents: number): string {
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
>
<CardChip class="w-full" />
<!-- The card server withheld this session's withdraw step (e.g.
the card's daily limit is spent). Nothing the customer does
here can lift it, so say so rather than offering a button
that always fails. The QR remains as the way to get paid. -->
<p
v-if="!atmStore.loadedBoltCard.withdraw"
class="w-full text-center text-sm font-medium text-destructive lg:text-lg"
>
{{
atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now'
}}
</p>
<Button
v-else
class="w-full bg-success text-success-foreground"
size="kiosk-lg"
:disabled="atmStore.boltCardProcessing"