From 70013d1c299c8bab428bf29dc0b25a4080a087c0 Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 23 Oct 2025 04:19:26 +0200 Subject: [PATCH] Enables manual settlement with fiat currencies Adds support for settling receivables with fiat currencies like EUR and USD, in addition to sats. Updates the settlement dialog to handle fiat amounts and exchange rates, defaulting to cash payment when a fiat balance exists. Modifies the API to accept currency and amount_sats parameters and adjust the journal entry accordingly, converting the fiat amount to minor units (e.g., cents) for accounting purposes. --- models.py | 4 +- static/js/index.js | 91 ++++++++++++++++++++++++++++++++----- templates/castle/index.html | 14 ++++-- views_api.py | 31 +++++++++++-- 4 files changed, 120 insertions(+), 20 deletions(-) diff --git a/models.py b/models.py index abd508f..2d6b861 100644 --- a/models.py +++ b/models.py @@ -186,10 +186,12 @@ class SettleReceivable(BaseModel): """Manually settle a receivable (user pays castle in person)""" user_id: str - amount: int # Amount in satoshis + amount: Decimal # Amount in the specified currency (or satoshis if currency is None) payment_method: str # "cash", "bank_transfer", "lightning", "other" description: str # Description of the payment reference: Optional[str] = None # Optional reference (receipt number, transaction ID, etc.) + currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.) + amount_sats: Optional[int] = None # Equivalent amount in sats (for reference/conversion tracking) class AssertionStatus(str, Enum): diff --git a/static/js/index.js b/static/js/index.js index 82272a0..2d76093 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -96,7 +96,9 @@ window.app = Vue.createApp({ invoice: null, paymentHash: null, checkWalletKey: null, - pollIntervalId: null + pollIntervalId: null, + exchangeRate: 3571.43, // sats per EUR (TODO: fetch from API) + originalCurrency: 'BTC' // Track original receivable currency } } }, @@ -114,6 +116,25 @@ window.app = Vue.createApp({ clearInterval(this.settleReceivableDialog.pollIntervalId) this.settleReceivableDialog.pollIntervalId = null } + }, + 'settleReceivableDialog.payment_method': function(newVal, oldVal) { + // Convert amount when payment method changes between cash and lightning + if (!oldVal) return + + const isOldCash = ['cash', 'bank_transfer', 'check'].includes(oldVal) + const isNewCash = ['cash', 'bank_transfer', 'check'].includes(newVal) + + // Only convert if switching between cash and lightning + if (isOldCash === isNewCash) return + + // Convert from fiat to sats (when switching from cash to lightning) + if (isOldCash && !isNewCash) { + this.settleReceivableDialog.amount = this.settleReceivableDialog.maxAmount + } + // Convert from sats to fiat (when switching from lightning to cash) + else if (!isOldCash && isNewCash) { + this.settleReceivableDialog.amount = this.settleReceivableDialog.maxAmountFiat || 0 + } } }, computed: { @@ -135,6 +156,30 @@ window.app = Vue.createApp({ } return 'Amount (sats) *' }, + settlementAmountLabel() { + const isCashPayment = ['cash', 'bank_transfer', 'check'].includes( + this.settleReceivableDialog.payment_method + ) + if (isCashPayment && this.settleReceivableDialog.fiatCurrency) { + return `Settlement Amount (${this.settleReceivableDialog.fiatCurrency}) *` + } + return 'Settlement Amount (sats) *' + }, + settlementMaxAmount() { + const isCashPayment = ['cash', 'bank_transfer', 'check'].includes( + this.settleReceivableDialog.payment_method + ) + if (isCashPayment && this.settleReceivableDialog.maxAmountFiat) { + return this.settleReceivableDialog.maxAmountFiat + } + return this.settleReceivableDialog.maxAmount + }, + settlementAmountStep() { + const isCashPayment = ['cash', 'bank_transfer', 'check'].includes( + this.settleReceivableDialog.payment_method + ) + return isCashPayment ? '0.01' : '1' + }, currencyOptions() { const options = [{label: 'Satoshis (default)', value: null}] this.currencies.forEach(curr => { @@ -883,20 +928,29 @@ window.app = Vue.createApp({ clearInterval(this.settleReceivableDialog.pollIntervalId) } + // Extract fiat balances (e.g., EUR) + const fiatBalances = userBalance.fiat_balances || {} + const fiatCurrency = Object.keys(fiatBalances)[0] || null // Get first fiat currency (e.g., 'EUR') + const fiatAmount = fiatCurrency ? Math.abs(fiatBalances[fiatCurrency]) : 0 + this.settleReceivableDialog = { show: true, user_id: userBalance.user_id, username: userBalance.username, - maxAmount: Math.abs(userBalance.balance), // Convert negative to positive - amount: Math.abs(userBalance.balance), // Default to full amount - payment_method: 'lightning', + maxAmount: Math.abs(userBalance.balance), // Sats amount + maxAmountFiat: fiatAmount, // EUR or other fiat amount + fiatCurrency: fiatCurrency, // 'EUR', 'USD', etc. + amount: fiatCurrency ? fiatAmount : Math.abs(userBalance.balance), // Default to fiat if available, otherwise sats + payment_method: fiatCurrency ? 'cash' : 'lightning', // Default to cash if fiat balance exists description: `Payment from ${userBalance.username}`, reference: '', loading: false, invoice: null, paymentHash: null, checkWalletKey: null, - pollIntervalId: null + pollIntervalId: null, + exchangeRate: fiatAmount > 0 ? Math.abs(userBalance.balance) / fiatAmount : 3571.43, // Calculate rate from actual amounts + originalCurrency: fiatCurrency || 'BTC' } }, async generateSettlementInvoice() { @@ -1018,17 +1072,30 @@ window.app = Vue.createApp({ async submitSettleReceivable() { this.settleReceivableDialog.loading = true try { + // Determine if this is a fiat payment + const isCashPayment = ['cash', 'bank_transfer', 'check'].includes( + this.settleReceivableDialog.payment_method + ) + + const payload = { + user_id: this.settleReceivableDialog.user_id, + amount: this.settleReceivableDialog.amount, + payment_method: this.settleReceivableDialog.payment_method, + description: this.settleReceivableDialog.description, + reference: this.settleReceivableDialog.reference || null, + } + + // Add currency info for fiat payments + if (isCashPayment && this.settleReceivableDialog.fiatCurrency) { + payload.currency = this.settleReceivableDialog.fiatCurrency + payload.amount_sats = this.settleReceivableDialog.maxAmount + } + const response = await LNbits.api.request( 'POST', '/castle/api/v1/receivables/settle', this.g.user.wallets[0].adminkey, - { - user_id: this.settleReceivableDialog.user_id, - amount: this.settleReceivableDialog.amount, - payment_method: this.settleReceivableDialog.payment_method, - description: this.settleReceivableDialog.description, - reference: this.settleReceivableDialog.reference || null - } + payload ) this.$q.notify({ diff --git a/templates/castle/index.html b/templates/castle/index.html index c75bcec..a198d25 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -1138,7 +1138,12 @@
Amount Owed
-
{% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats
+
+ {% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats +
+
+ {% raw %}{{ formatFiat(settleReceivableDialog.maxAmountFiat, settleReceivableDialog.fiatCurrency) }}{% endraw %} +
diff --git a/views_api.py b/views_api.py index e256320..f6a41dc 100644 --- a/views_api.py +++ b/views_api.py @@ -826,6 +826,26 @@ async def api_settle_receivable( # DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased) # This records that user paid their debt + # Convert amount to sats (minor units) + # For fiat currencies, store as cents/minor units + # For satoshis, just convert to int + from decimal import Decimal + + if data.currency: + # Fiat currency payment (e.g., EUR, USD) + amount_minor_units = int(data.amount * 100) # Convert to cents + line_metadata = { + "fiat_currency": data.currency, + "fiat_amount": str(data.amount), + } + if data.amount_sats: + line_metadata["sats_equivalent"] = data.amount_sats + line_metadata["exchange_rate"] = data.amount_sats / float(data.amount) + else: + # Satoshi payment + amount_minor_units = int(data.amount) + line_metadata = {} + # Add meta information for audit trail entry_meta = { "source": "manual_settlement", @@ -833,6 +853,8 @@ async def api_settle_receivable( "settled_by": wallet.wallet.user, "payer_user_id": data.user_id, } + if data.currency: + entry_meta["currency"] = data.currency entry_data = CreateJournalEntry( description=data.description, @@ -842,15 +864,17 @@ async def api_settle_receivable( lines=[ CreateEntryLine( account_id=payment_account.id, - debit=data.amount, + debit=amount_minor_units, credit=0, description=f"Payment received via {data.payment_method}", + metadata=line_metadata, ), CreateEntryLine( account_id=user_receivable.id, debit=0, - credit=data.amount, + credit=amount_minor_units, description="Receivable settled", + metadata=line_metadata, ), ], ) @@ -863,7 +887,8 @@ async def api_settle_receivable( return { "journal_entry_id": entry.id, "user_id": data.user_id, - "amount_settled": data.amount, + "amount_settled": float(data.amount), + "currency": data.currency, "payment_method": data.payment_method, "new_balance": balance.balance, "message": f"Receivable settled successfully via {data.payment_method}",