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