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.
This commit is contained in:
padreug 2025-10-22 13:32:10 +02:00
parent 4bd83d6937
commit cd083114b4
5 changed files with 97 additions and 10 deletions

View file

@ -61,7 +61,8 @@ async def m001_initial(db):
account_id TEXT NOT NULL, account_id TEXT NOT NULL,
debit INTEGER NOT NULL DEFAULT 0, debit INTEGER NOT NULL DEFAULT 0,
credit INTEGER NOT NULL DEFAULT 0, credit INTEGER NOT NULL DEFAULT 0,
description TEXT description TEXT,
metadata TEXT DEFAULT '{{}}'
); );
""" """
) )

View file

@ -36,6 +36,7 @@ class EntryLine(BaseModel):
debit: int = 0 # in satoshis debit: int = 0 # in satoshis
credit: int = 0 # in satoshis credit: int = 0 # in satoshis
description: Optional[str] = None description: Optional[str] = None
metadata: dict = {} # Stores currency info: fiat_currency, fiat_amount, fiat_rate, etc.
class CreateEntryLine(BaseModel): class CreateEntryLine(BaseModel):
@ -43,6 +44,7 @@ class CreateEntryLine(BaseModel):
debit: int = 0 debit: int = 0
credit: int = 0 credit: int = 0
description: Optional[str] = None description: Optional[str] = None
metadata: dict = {} # Stores currency info
class JournalEntry(BaseModel): class JournalEntry(BaseModel):
@ -72,28 +74,31 @@ class ExpenseEntry(BaseModel):
"""Helper model for creating expense entries""" """Helper model for creating expense entries"""
description: str 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 expense_account: str # account name or ID
is_equity: bool = False # True = equity contribution, False = liability (castle owes user) is_equity: bool = False # True = equity contribution, False = liability (castle owes user)
user_wallet: str user_wallet: str
reference: Optional[str] = None reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code (EUR, USD, etc.)
class ReceivableEntry(BaseModel): class ReceivableEntry(BaseModel):
"""Helper model for creating accounts receivable entries""" """Helper model for creating accounts receivable entries"""
description: str 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 revenue_account: str # account name or ID
user_wallet: str user_wallet: str
reference: Optional[str] = None reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code
class RevenueEntry(BaseModel): class RevenueEntry(BaseModel):
"""Helper model for creating revenue entries""" """Helper model for creating revenue entries"""
description: str description: str
amount: int # in satoshis amount: float # Amount in the specified currency (or satoshis if currency is None)
revenue_account: str revenue_account: str
payment_method_account: str # e.g., "Cash", "Bank", "Lightning" payment_method_account: str # e.g., "Cash", "Bank", "Lightning"
reference: Optional[str] = None reference: Optional[str] = None
currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code

View file

@ -10,6 +10,7 @@ window.app = Vue.createApp({
balance: null, balance: null,
transactions: [], transactions: [],
accounts: [], accounts: [],
currencies: [],
expenseDialog: { expenseDialog: {
show: false, show: false,
description: '', description: '',
@ -17,6 +18,7 @@ window.app = Vue.createApp({
expenseAccount: '', expenseAccount: '',
isEquity: false, isEquity: false,
reference: '', reference: '',
currency: null,
loading: false loading: false
}, },
payDialog: { payDialog: {
@ -29,6 +31,19 @@ window.app = Vue.createApp({
computed: { computed: {
expenseAccounts() { expenseAccounts() {
return this.accounts.filter(a => a.account_type === 'expense') 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: { methods: {
@ -68,6 +83,18 @@ window.app = Vue.createApp({
LNbits.utils.notifyApiError(error) 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() { async submitExpense() {
this.expenseDialog.loading = true this.expenseDialog.loading = true
try { try {
@ -81,7 +108,8 @@ window.app = Vue.createApp({
expense_account: this.expenseDialog.expenseAccount, expense_account: this.expenseDialog.expenseAccount,
is_equity: this.expenseDialog.isEquity, is_equity: this.expenseDialog.isEquity,
user_wallet: this.g.user.wallets[0].id, 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({ this.$q.notify({
@ -142,6 +170,7 @@ window.app = Vue.createApp({
this.expenseDialog.expenseAccount = '' this.expenseDialog.expenseAccount = ''
this.expenseDialog.isEquity = false this.expenseDialog.isEquity = false
this.expenseDialog.reference = '' this.expenseDialog.reference = ''
this.expenseDialog.currency = null
}, },
formatSats(amount) { formatSats(amount) {
return new Intl.NumberFormat().format(amount) return new Intl.NumberFormat().format(amount)
@ -158,5 +187,6 @@ window.app = Vue.createApp({
await this.loadBalance() await this.loadBalance()
await this.loadTransactions() await this.loadTransactions()
await this.loadAccounts() await this.loadAccounts()
await this.loadCurrencies()
} }
}) })

View file

@ -136,13 +136,26 @@
placeholder="e.g., Groceries for the house" placeholder="e.g., Groceries for the house"
></q-input> ></q-input>
<q-select
filled
dense
v-model="expenseDialog.currency"
:options="currencyOptions"
option-label="label"
option-value="value"
emit-value
map-options
label="Currency"
></q-select>
<q-input <q-input
filled filled
dense dense
v-model.number="expenseDialog.amount" v-model.number="expenseDialog.amount"
type="number" type="number"
label="Amount (sats) *" :label="amountLabel"
min="1" min="0.01"
step="0.01"
></q-input> ></q-input>
<q-select <q-select

View file

@ -3,6 +3,7 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import require_admin_key, require_invoice_key from lnbits.decorators import require_admin_key, require_invoice_key
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from .crud import ( from .crud import (
create_account, create_account,
@ -34,6 +35,15 @@ from .models import (
castle_api_router = APIRouter(prefix="/api/v1", tags=["castle"]) castle_api_router = APIRouter(prefix="/api/v1", tags=["castle"])
# ===== UTILITY ENDPOINTS =====
@castle_api_router.get("/currencies")
async def api_get_currencies() -> list[str]:
"""Get list of allowed currencies for fiat conversion"""
return allowed_currencies()
# ===== ACCOUNT ENDPOINTS ===== # ===== ACCOUNT ENDPOINTS =====
@ -136,7 +146,32 @@ async def api_create_expense_entry(
Create an expense entry for a user. Create an expense entry for a user.
If is_equity=True, records as equity contribution. If is_equity=True, records as equity contribution.
If is_equity=False, records as liability (castle owes user). 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 # Get or create expense account
expense_account = await get_account_by_name(data.expense_account) expense_account = await get_account_by_name(data.expense_account)
if not expense_account: if not expense_account:
@ -162,21 +197,24 @@ async def api_create_expense_entry(
# Create journal entry # Create journal entry
# DR Expense, CR User Account (Liability or Equity) # DR Expense, CR User Account (Liability or Equity)
description_suffix = f" ({metadata['fiat_amount']} {metadata['fiat_currency']})" if metadata else ""
entry_data = CreateJournalEntry( entry_data = CreateJournalEntry(
description=data.description, description=data.description + description_suffix,
reference=data.reference, reference=data.reference,
lines=[ lines=[
CreateEntryLine( CreateEntryLine(
account_id=expense_account.id, account_id=expense_account.id,
debit=data.amount, debit=amount_sats,
credit=0, credit=0,
description=f"Expense paid by user {data.user_wallet[:8]}", description=f"Expense paid by user {data.user_wallet[:8]}",
metadata=metadata,
), ),
CreateEntryLine( CreateEntryLine(
account_id=user_account.id, account_id=user_account.id,
debit=0, debit=0,
credit=data.amount, credit=amount_sats,
description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}", description=f"{'Equity contribution' if data.is_equity else 'Amount owed to user'}",
metadata=metadata,
), ),
], ],
) )