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' + }} +