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, ), ], )