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:
parent
49f21da55a
commit
70013d1c29
4 changed files with 120 additions and 20 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
31
views_api.py
31
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}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue