From 61d5bf0231fb78f7a9dfaf2a51699b7e058581e9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 17:50:49 +0200 Subject: [PATCH] fix(access): instrument Complete, and say when a card can't sell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/src/stores/atm.ts | 42 ++++++++++++++++++++------ apps/machine/src/views/CashOutView.vue | 13 ++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f9af8cb..0ed4719 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -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' diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 50a43a2..896f165 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -353,7 +353,20 @@ function formatFiat(cents: number): string { class="flex w-full max-w-md flex-col items-center gap-2 pt-2" > + +

+ {{ + atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now' + }} +