From 16159334a48dbf58add7435eb85cebedf3fc93ef Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 17:21:45 -0500 Subject: [PATCH] feat(machine): limit bill insertion to available balance Add billWithinBalance guard to state machine that rejects bills exceeding the ATM's available sats. UI disables denomination buttons and shows informational message when limit is reached. Co-Authored-By: Claude Opus 4.6 --- apps/machine/src/views/CashInView.vue | 38 +++++++++++++++++++++++++-- packages/state-machine/src/machine.ts | 9 +++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index 48c6d02..d907f15 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -114,6 +114,23 @@ function skipReceipt() { atmStore.skipReceipt() } +// Check if a bill denomination can still be accepted without exceeding available balance +function canAcceptBill(denomination: number): boolean { + const ctx = context.value + if (!ctx) return false + if (ctx.availableBalance <= 0 || ctx.exchangeRate === 0) return true + const newFiatCents = ctx.fiatAmount + denomination * 100 + const grossSats = Math.floor((newFiatCents / 100) * ctx.exchangeRate) + const fee = Math.floor(grossSats * ctx.feePercent) + return grossSats - fee <= ctx.availableBalance +} + +const balanceLimitReached = computed(() => { + if (!context.value || context.value.availableBalance <= 0) return false + // Check if even the smallest denomination ($1) would exceed + return !canAcceptBill(1) +}) + // Simulate bill insertion (for development) function simulateBill(denomination: number) { atmStore.insertBill(denomination) @@ -210,10 +227,26 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque + +
+ Available: + + {{ formatSats(context.availableBalance) }} sats + +
+
-
💵 ➡️ 🏧
-

Insert bills into the acceptor

+
+
💰
+

+ Maximum amount reached — press Done to continue +

+
+
+
💵 ➡️ 🏧
+

Insert bills into the acceptor

+
@@ -227,6 +260,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque variant="outline" size="sm" class="h-8" + :disabled="!canAcceptBill(denom)" @click="simulateBill(denom)" > ${{ denom }} diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 2e0d918..e3260a5 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -331,6 +331,14 @@ export function createATMMachine(services: Partial = {}) { canRetry: ({ context }) => context.retryCount < 3, hasUserNpub: ({ context }) => context.userNpub !== null, hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null, + billWithinBalance: ({ context, event }) => { + if (event.type !== 'BILL_INSERTED') return false + if (context.availableBalance <= 0 || context.exchangeRate === 0) return true + const newFiatCents = context.fiatAmount + event.denomination * 100 + const grossSats = Math.floor((newFiatCents / 100) * context.exchangeRate) + const fee = Math.floor(grossSats * context.feePercent) + return grossSats - fee <= context.availableBalance + }, // Cash-out guards hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0, canAddDenomination: ({ context, event }) => { @@ -392,6 +400,7 @@ export function createATMMachine(services: Partial = {}) { insertingBills: { on: { BILL_INSERTED: { + guard: 'billWithinBalance', actions: ['addBill', 'calculateSats'], }, BILL_REJECTED: {