From cd083114b4b1f3ba7230966ddd7e3501d1ec3e3c Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 22 Oct 2025 13:32:10 +0200 Subject: [PATCH] Adds fiat currency support for expenses Extends expense entry functionality to support fiat currencies. Users can now specify a currency (e.g., EUR, USD) when creating expense entries. The specified amount is converted to satoshis using exchange rates. The converted amount and currency information are stored in the journal entry metadata. Also adds an API endpoint to retrieve allowed currencies and updates the UI to allow currency selection when creating expense entries. --- migrations.py | 3 ++- models.py | 11 +++++++--- static/js/index.js | 32 ++++++++++++++++++++++++++- templates/castle/index.html | 17 ++++++++++++-- views_api.py | 44 ++++++++++++++++++++++++++++++++++--- 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/migrations.py b/migrations.py index bfca2ee..e827fc0 100644 --- a/migrations.py +++ b/migrations.py @@ -61,7 +61,8 @@ async def m001_initial(db): account_id TEXT NOT NULL, debit INTEGER NOT NULL DEFAULT 0, credit INTEGER NOT NULL DEFAULT 0, - description TEXT + description TEXT, + metadata TEXT DEFAULT '{{}}' ); """ ) diff --git a/models.py b/models.py index bc19eb8..185d72e 100644 --- a/models.py +++ b/models.py @@ -36,6 +36,7 @@ class EntryLine(BaseModel): debit: int = 0 # in satoshis credit: int = 0 # in satoshis description: Optional[str] = None + metadata: dict = {} # Stores currency info: fiat_currency, fiat_amount, fiat_rate, etc. class CreateEntryLine(BaseModel): @@ -43,6 +44,7 @@ class CreateEntryLine(BaseModel): debit: int = 0 credit: int = 0 description: Optional[str] = None + metadata: dict = {} # Stores currency info class JournalEntry(BaseModel): @@ -72,28 +74,31 @@ class ExpenseEntry(BaseModel): """Helper model for creating expense entries""" description: str - amount: int # in satoshis + amount: float # Amount in the specified currency (or satoshis if currency is None) expense_account: str # account name or ID is_equity: bool = False # True = equity contribution, False = liability (castle owes user) user_wallet: str reference: Optional[str] = None + currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.) class ReceivableEntry(BaseModel): """Helper model for creating accounts receivable entries""" description: str - amount: int # in satoshis + amount: float # Amount in the specified currency (or satoshis if currency is None) revenue_account: str # account name or ID user_wallet: str reference: Optional[str] = None + currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code class RevenueEntry(BaseModel): """Helper model for creating revenue entries""" description: str - amount: int # in satoshis + amount: float # Amount in the specified currency (or satoshis if currency is None) revenue_account: str payment_method_account: str # e.g., "Cash", "Bank", "Lightning" reference: Optional[str] = None + currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code diff --git a/static/js/index.js b/static/js/index.js index db1d768..ade58c9 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -10,6 +10,7 @@ window.app = Vue.createApp({ balance: null, transactions: [], accounts: [], + currencies: [], expenseDialog: { show: false, description: '', @@ -17,6 +18,7 @@ window.app = Vue.createApp({ expenseAccount: '', isEquity: false, reference: '', + currency: null, loading: false }, payDialog: { @@ -29,6 +31,19 @@ window.app = Vue.createApp({ computed: { expenseAccounts() { return this.accounts.filter(a => a.account_type === 'expense') + }, + amountLabel() { + if (this.expenseDialog.currency) { + return `Amount (${this.expenseDialog.currency}) *` + } + return 'Amount (sats) *' + }, + currencyOptions() { + const options = [{label: 'Satoshis (default)', value: null}] + this.currencies.forEach(curr => { + options.push({label: curr, value: curr}) + }) + return options } }, methods: { @@ -68,6 +83,18 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) } }, + async loadCurrencies() { + try { + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/currencies', + this.g.user.wallets[0].inkey + ) + this.currencies = response.data + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, async submitExpense() { this.expenseDialog.loading = true try { @@ -81,7 +108,8 @@ window.app = Vue.createApp({ expense_account: this.expenseDialog.expenseAccount, is_equity: this.expenseDialog.isEquity, user_wallet: this.g.user.wallets[0].id, - reference: this.expenseDialog.reference || null + reference: this.expenseDialog.reference || null, + currency: this.expenseDialog.currency || null } ) this.$q.notify({ @@ -142,6 +170,7 @@ window.app = Vue.createApp({ this.expenseDialog.expenseAccount = '' this.expenseDialog.isEquity = false this.expenseDialog.reference = '' + this.expenseDialog.currency = null }, formatSats(amount) { return new Intl.NumberFormat().format(amount) @@ -158,5 +187,6 @@ window.app = Vue.createApp({ await this.loadBalance() await this.loadTransactions() await this.loadAccounts() + await this.loadCurrencies() } }) diff --git a/templates/castle/index.html b/templates/castle/index.html index ed3cbf7..d54d21d 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -136,13 +136,26 @@ placeholder="e.g., Groceries for the house" > + + list[str]: + """Get list of allowed currencies for fiat conversion""" + return allowed_currencies() + + # ===== ACCOUNT ENDPOINTS ===== @@ -136,7 +146,32 @@ async def api_create_expense_entry( Create an expense entry for a user. If is_equity=True, records as equity contribution. If is_equity=False, records as liability (castle owes user). + + If currency is provided, amount is converted from fiat to satoshis. """ + # Handle currency conversion + amount_sats = int(data.amount) + metadata = {} + + if data.currency: + # Validate currency + if data.currency.upper() not in allowed_currencies(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", + ) + + # Convert fiat to satoshis + amount_sats = await fiat_amount_as_satoshis(data.amount, data.currency) + + # Store currency metadata + metadata = { + "fiat_currency": data.currency.upper(), + "fiat_amount": round(data.amount, ndigits=3), + "fiat_rate": amount_sats / data.amount if data.amount > 0 else 0, + "btc_rate": (data.amount / amount_sats * 100_000_000) if amount_sats > 0 else 0, + } + # Get or create expense account expense_account = await get_account_by_name(data.expense_account) if not expense_account: @@ -162,21 +197,24 @@ async def api_create_expense_entry( # Create journal entry # DR Expense, CR User Account (Liability or Equity) + description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else "" entry_data = CreateJournalEntry( - description=data.description, + description=data.description + description_suffix, reference=data.reference, lines=[ CreateEntryLine( account_id=expense_account.id, - debit=data.amount, + debit=amount_sats, credit=0, description=f"Expense paid by user {data.user_wallet[:8]}", + metadata=metadata, ), CreateEntryLine( account_id=user_account.id, debit=0, - credit=data.amount, + credit=amount_sats, description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", + metadata=metadata, ), ], )