diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 46ac786..5060beb 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -271,7 +271,7 @@ export const useAtmStore = defineStore('atm', () => { persistTransaction({ txid: ctx.txid, type: isCashInTx ? 'cash_in' : 'cash_out', - fiatCents: ctx.fiatAmount, + fiatCents: ctx.fiatCents, sats: ctx.satsAmount, bills, }) @@ -626,7 +626,7 @@ export const useAtmStore = defineStore('atm', () => { } // Calculate what the new sats amount would be - const newFiatCents = ctx.fiatAmount + denomination * 100 + const newFiatCents = ctx.fiatCents + denomination * 100 const newFiatUnits = newFiatCents / 100 const grossSats = Math.floor(newFiatUnits * ctx.exchangeRate) const fee = Math.floor(grossSats * ctx.feePercent) @@ -846,7 +846,7 @@ export const useAtmStore = defineStore('atm', () => { } // Calculate if this bill would exceed available balance - const newFiatCents = ctx.fiatAmount + denomination * 100 + const newFiatCents = ctx.fiatCents + denomination * 100 const newFiatUnits = newFiatCents / 100 const grossSats = Math.floor(newFiatUnits * ctx.exchangeRate) const fee = Math.floor(grossSats * ctx.feePercent) diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index 56a59d0..b26752b 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -119,7 +119,7 @@ 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 newFiatCents = ctx.fiatCents + denomination * 100 const grossSats = Math.floor((newFiatCents / 100) * ctx.exchangeRate) const fee = Math.floor(grossSats * ctx.feePercent) return grossSats - fee <= ctx.availableBalance @@ -214,7 +214,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)

- {{ atmStore.fiatSymbol }}{{ (context.fiatAmount / 100).toFixed(2) }} + {{ atmStore.fiatSymbol }}{{ (context.fiatCents / 100).toFixed(2) }}

{{ formatSats(context.satsAmount) }} sats @@ -322,7 +322,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice) {{ formatSats(context?.satsAmount || 0) }} sats

- {{ atmStore.fiatSymbol }}{{ ((context?.fiatAmount || 0) / 100).toFixed(2) }} + {{ atmStore.fiatSymbol }}{{ ((context?.fiatCents || 0) / 100).toFixed(2) }}

@@ -388,7 +388,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)

Cash Already Accepted

Your {{ atmStore.fiatSymbol - }}{{ context ? (context.fiatAmount / 100).toLocaleString() : '' }} has been accepted and + }}{{ context ? (context.fiatCents / 100).toLocaleString() : '' }} has been accepted and cannot be returned. Please scan the QR code to receive your Bitcoin.

diff --git a/packages/state-machine/src/__tests__/machine.test.ts b/packages/state-machine/src/__tests__/machine.test.ts index 8205f84..ff624e3 100644 --- a/packages/state-machine/src/__tests__/machine.test.ts +++ b/packages/state-machine/src/__tests__/machine.test.ts @@ -39,7 +39,7 @@ describe('ATM State Machine', () => { actor.start() const context = actor.getSnapshot().context - expect(context.fiatAmount).toBe(0) + expect(context.fiatCents).toBe(0) expect(context.satsAmount).toBe(0) expect(context.billsInserted).toEqual([]) expect(context.error).toBeNull() @@ -78,7 +78,7 @@ describe('ATM State Machine', () => { const context = actor.getSnapshot().context expect(context.billsInserted).toEqual([20, 10]) - expect(context.fiatAmount).toBe(3000) // $30 in cents + expect(context.fiatCents).toBe(3000) // $30 in cents }) it('should return to idle on CANCEL', async () => { @@ -141,14 +141,14 @@ describe('ATM State Machine', () => { let state = actor.getSnapshot() expect(state.context.cashOutSelection).toEqual([20, 20, 20]) - expect(state.context.fiatAmount).toBe(6000) // $60 in cents + expect(state.context.fiatCents).toBe(6000) // $60 in cents // Remove one actor.send({ type: 'REMOVE_DENOMINATION', denomination: 20 }) state = actor.getSnapshot() expect(state.context.cashOutSelection).toEqual([20, 20]) - expect(state.context.fiatAmount).toBe(4000) // $40 in cents + expect(state.context.fiatCents).toBe(4000) // $40 in cents }) it('should calculate sats amount from fiat with fee', async () => { @@ -164,8 +164,8 @@ describe('ATM State Machine', () => { const state = actor.getSnapshot() // $20 at 2500 sats/USD = 50,000 sats - // Plus 2% fee = 51,000 sats - expect(state.context.satsAmount).toBe(51000) + // Plus 7.77% cash-out fee = floor(50000 * 0.0777) = 3,885 → 53,885 sats + expect(state.context.satsAmount).toBe(53885) }) it('should generate invoice after confirming amount', async () => { @@ -396,7 +396,7 @@ describe('ATM State Machine', () => { actor.send({ type: 'RETRY' }) await new Promise((resolve) => setTimeout(resolve, 50)) - // Should have retried and succeeded + // Rate fetch failed → retry goes to fetchingRate → succeeds → insertingBills const state = actor.getSnapshot() expect(state.value).toMatchObject({ cashIn: 'insertingBills' }) }) diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index 5f0919a..05b6651 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -180,17 +180,16 @@ export function createATMMachine( if (event.type !== 'BILL_INSERTED') return context.billsInserted return [...context.billsInserted, event.denomination] }, - fiatAmount: ({ context, event }) => { - if (event.type !== 'BILL_INSERTED') return context.fiatAmount - // denomination is in dollars, fiatAmount is in cents - return context.fiatAmount + event.denomination * 100 + fiatCents: ({ context, event }) => { + if (event.type !== 'BILL_INSERTED') return context.fiatCents + return context.fiatCents + event.denomination * 100 }, }), calculateSats: assign({ satsAmount: ({ context }) => { if (context.exchangeRate === 0) return 0 - const fiatUnits = context.fiatAmount / 100 // cents to dollars - const grossSats = Math.floor(fiatUnits * context.exchangeRate) + const fiatDollars = context.fiatCents / 100 + const grossSats = Math.floor(fiatDollars * context.exchangeRate) const fee = Math.floor(grossSats * context.feePercent) return grossSats - fee }, @@ -200,7 +199,7 @@ export function createATMMachine( // Calculate bills to dispense for cash-out // Simple algorithm: use largest denominations first const denominations = [100, 50, 20, 10, 5, 1] - let remaining = context.fiatAmount / 100 // cents to dollars + let remaining = context.fiatCents / 100 const amounts: { denomination: number; count: number }[] = [] for (const denom of denominations) { @@ -278,7 +277,7 @@ export function createATMMachine( }, }), setAmount: assign({ - fiatAmount: ({ event }) => { + fiatCents: ({ event }) => { if (event.type !== 'SELECT_AMOUNT') return 0 return event.amount * 100 // dollars to cents }, @@ -296,9 +295,9 @@ export function createATMMachine( if (event.type !== 'ADD_DENOMINATION') return context.cashOutSelection return [...context.cashOutSelection, event.denomination] }, - fiatAmount: ({ context, event }) => { - if (event.type !== 'ADD_DENOMINATION') return context.fiatAmount - return context.fiatAmount + event.denomination * 100 // dollars to cents + fiatCents: ({ context, event }) => { + if (event.type !== 'ADD_DENOMINATION') return context.fiatCents + return context.fiatCents + event.denomination * 100 }, }), removeDenomination: assign({ @@ -310,21 +309,21 @@ export function createATMMachine( newSelection.splice(idx, 1) return newSelection }, - fiatAmount: ({ context, event }) => { - if (event.type !== 'REMOVE_DENOMINATION') return context.fiatAmount - if (!context.cashOutSelection.includes(event.denomination)) return context.fiatAmount - return context.fiatAmount - event.denomination * 100 // dollars to cents + fiatCents: ({ context, event }) => { + if (event.type !== 'REMOVE_DENOMINATION') return context.fiatCents + if (!context.cashOutSelection.includes(event.denomination)) return context.fiatCents + return context.fiatCents - event.denomination * 100 }, }), clearCashOutSelection: assign({ cashOutSelection: () => [], - fiatAmount: () => 0, + fiatCents: () => 0, satsAmount: () => 0, }), calculateSatsFromFiat: assign({ satsAmount: ({ context }) => { if (context.exchangeRate === 0) return 0 - const fiatDollars = context.fiatAmount / 100 // cents to dollars + const fiatDollars = context.fiatCents / 100 const grossSats = Math.floor(fiatDollars * context.exchangeRate) // For cash-out, user pays the sats, so fee is added const fee = Math.floor(grossSats * context.feePercent) @@ -362,7 +361,7 @@ export function createATMMachine( }, guards: { hasInsertedBills: ({ context }) => context.billsInserted.length > 0, - hasSufficientAmount: ({ context }) => context.fiatAmount >= 100, // $1 minimum + hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum hasExchangeRate: ({ context }) => context.exchangeRate > 0, canRetry: ({ context }) => context.retryCount < 3, hasUserNpub: ({ context }) => context.userNpub !== null, @@ -370,7 +369,7 @@ export function createATMMachine( 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 newFiatCents = context.fiatCents + event.denomination * 100 const grossSats = Math.floor((newFiatCents / 100) * context.exchangeRate) const fee = Math.floor(grossSats * context.feePercent) return grossSats - fee <= context.availableBalance @@ -543,11 +542,26 @@ export function createATMMachine( }, error: { on: { - RETRY: { - guard: 'canRetry', - target: 'generatingNdebit', - actions: 'incrementRetry', - }, + RETRY: [ + { + // Rate never loaded: retry from rate fetch + guard: ({ context }) => context.retryCount < 3 && context.exchangeRate === 0, + target: 'fetchingRate', + actions: 'incrementRetry', + }, + { + // Ndebit failed before QR was shown: retry generation + guard: ({ context }) => context.retryCount < 3 && context.ndebitUri === null, + target: 'generatingNdebit', + actions: 'incrementRetry', + }, + { + // Payment failed but QR/LNURL still valid: show QR again + guard: 'canRetry', + target: 'displayingQR', + actions: 'incrementRetry', + }, + ], CANCEL: [ { // No bills inserted: safe to cancel diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index c26aa2f..83d78c1 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -34,7 +34,7 @@ export interface OfferRequestEvent { export interface ATMContext { // Transaction details /** Fiat amount in cents */ - fiatAmount: number + fiatCents: number /** Satoshi amount */ satsAmount: number /** Fiat currency code */ @@ -141,7 +141,7 @@ export type ATMEvent = /** Initial context values */ export const initialContext: ATMContext = { - fiatAmount: 0, + fiatCents: 0, satsAmount: 0, currency: 'USD', exchangeRate: 0,