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.
This commit is contained in:
padreug 2025-10-23 04:19:26 +02:00
parent 49f21da55a
commit 70013d1c29
4 changed files with 120 additions and 20 deletions

View file

@ -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):

View file

@ -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({

View file

@ -1138,7 +1138,12 @@
<div class="q-mb-md">
<div class="text-subtitle2">Amount Owed</div>
<div class="text-negative text-h6">{% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats</div>
<div class="text-negative text-h6">
{% raw %}{{ formatSats(settleReceivableDialog.maxAmount) }}{% endraw %} sats
</div>
<div v-if="settleReceivableDialog.fiatCurrency && settleReceivableDialog.maxAmountFiat" class="text-caption">
{% raw %}{{ formatFiat(settleReceivableDialog.maxAmountFiat, settleReceivableDialog.fiatCurrency) }}{% endraw %}
</div>
</div>
<q-input
@ -1146,13 +1151,14 @@
dense
v-model.number="settleReceivableDialog.amount"
type="number"
label="Settlement Amount (sats) *"
:label="settlementAmountLabel"
hint="Amount user is paying (max: owed amount)"
:max="settleReceivableDialog.maxAmount"
:max="settlementMaxAmount"
:step="settlementAmountStep"
:rules="[
val => val !== null && val !== undefined && val !== '' || 'Amount is required',
val => val > 0 || 'Amount must be positive',
val => val <= settleReceivableDialog.maxAmount || 'Cannot exceed owed amount'
val => val <= settlementMaxAmount || 'Cannot exceed owed amount'
]"
></q-input>

View file

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