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}",