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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-16 17:21:45 -05:00
commit 16159334a4
2 changed files with 45 additions and 2 deletions

View file

@ -114,6 +114,23 @@ function skipReceipt() {
atmStore.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) // Simulate bill insertion (for development)
function simulateBill(denomination: number) { function simulateBill(denomination: number) {
atmStore.insertBill(denomination) atmStore.insertBill(denomination)
@ -210,11 +227,27 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</div> </div>
</div> </div>
<!-- Available balance indicator -->
<div v-if="context && context.availableBalance > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">Available:</span>
<span :class="balanceLimitReached ? 'text-foreground' : 'text-muted-foreground'">
{{ formatSats(context.availableBalance) }} sats
</span>
</div>
<!-- Bill Acceptor Visual --> <!-- Bill Acceptor Visual -->
<div class="rounded-lg border-2 border-dashed border-white/20 p-6 text-center"> <div class="rounded-lg border-2 border-dashed border-white/20 p-6 text-center">
<div v-if="balanceLimitReached">
<div class="mb-2 text-4xl">💰</div>
<p class="text-sm text-muted-foreground">
Maximum amount reached — press Done to continue
</p>
</div>
<div v-else>
<div class="mb-2 text-4xl">💵 ➡️ 🏧</div> <div class="mb-2 text-4xl">💵 ➡️ 🏧</div>
<p class="text-sm text-muted-foreground">Insert bills into the acceptor</p> <p class="text-sm text-muted-foreground">Insert bills into the acceptor</p>
</div> </div>
</div>
<!-- Dev: Simulate bill insertion --> <!-- Dev: Simulate bill insertion -->
<Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10"> <Alert v-if="atmStore.debugMode" class="border-red-500/50 bg-red-500/10">
@ -227,6 +260,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8" class="h-8"
:disabled="!canAcceptBill(denom)"
@click="simulateBill(denom)" @click="simulateBill(denom)"
> >
${{ denom }} ${{ denom }}

View file

@ -331,6 +331,14 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
canRetry: ({ context }) => context.retryCount < 3, canRetry: ({ context }) => context.retryCount < 3,
hasUserNpub: ({ context }) => context.userNpub !== null, hasUserNpub: ({ context }) => context.userNpub !== null,
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== 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 // Cash-out guards
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0, hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
canAddDenomination: ({ context, event }) => { canAddDenomination: ({ context, event }) => {
@ -392,6 +400,7 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
insertingBills: { insertingBills: {
on: { on: {
BILL_INSERTED: { BILL_INSERTED: {
guard: 'billWithinBalance',
actions: ['addBill', 'calculateSats'], actions: ['addBill', 'calculateSats'],
}, },
BILL_REJECTED: { BILL_REJECTED: {