From 2a14dd2e62cd6005a94003fba60c327a1e89169e Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 22 Oct 2025 16:16:36 +0200 Subject: [PATCH] Adds receivable entry functionality Implements the ability to record receivables (user owes the castle). Adds API endpoint for creating receivable entries, which includes currency conversion to satoshis if fiat currency is provided. Integrates a UI component (receivable dialog) for superusers to record debts owed by users, enhancing financial tracking capabilities. --- crud.py | 9 ++++ static/js/index.js | 91 ++++++++++++++++++++++++++++++++++++ templates/castle/index.html | 92 +++++++++++++++++++++++++++++++++++++ views_api.py | 61 ++++++++++++++++++++++-- 4 files changed, 250 insertions(+), 3 deletions(-) diff --git a/crud.py b/crud.py index 9097421..2f85952 100644 --- a/crud.py +++ b/crud.py @@ -446,3 +446,12 @@ async def update_user_wallet_settings( settings = StoredUserWalletSettings(**data.dict(), id=user_id) await db.update("user_wallet_settings", settings) return settings + + +async def get_all_user_wallet_settings() -> list[StoredUserWalletSettings]: + """Get all user wallet settings""" + return await db.fetchall( + "SELECT * FROM user_wallet_settings ORDER BY id", + {}, + StoredUserWalletSettings, + ) diff --git a/static/js/index.js b/static/js/index.js index 816d846..0c1fb09 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -12,6 +12,7 @@ window.app = Vue.createApp({ transactions: [], accounts: [], currencies: [], + users: [], settings: null, userWalletSettings: null, isAdmin: false, @@ -42,6 +43,16 @@ window.app = Vue.createApp({ show: false, userWalletId: '', loading: false + }, + receivableDialog: { + show: false, + selectedUser: '', + description: '', + amount: null, + revenueAccount: '', + reference: '', + currency: null, + loading: false } } }, @@ -49,18 +60,37 @@ window.app = Vue.createApp({ expenseAccounts() { return this.accounts.filter(a => a.account_type === 'expense') }, + revenueAccounts() { + return this.accounts.filter(a => a.account_type === 'revenue') + }, amountLabel() { if (this.expenseDialog.currency) { return `Amount (${this.expenseDialog.currency}) *` } return 'Amount (sats) *' }, + receivableAmountLabel() { + if (this.receivableDialog.currency) { + return `Amount (${this.receivableDialog.currency}) *` + } + return 'Amount (sats) *' + }, currencyOptions() { const options = [{label: 'Satoshis (default)', value: null}] this.currencies.forEach(curr => { options.push({label: curr, value: curr}) }) return options + }, + userOptions() { + const options = [] + this.users.forEach(user => { + options.push({ + label: user.username, + value: user.user_wallet_id + }) + }) + return options } }, methods: { @@ -129,6 +159,18 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) } }, + async loadUsers() { + try { + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/users', + this.g.user.wallets[0].adminkey + ) + this.users = response.data + } catch (error) { + console.error('Error loading users:', error) + } + }, async loadSettings() { try { // Try with admin key first to check settings @@ -304,6 +346,43 @@ window.app = Vue.createApp({ this.payDialog.amount = Math.abs(this.balance.balance) this.payDialog.show = true }, + async showReceivableDialog() { + // Load users if not already loaded + if (this.users.length === 0 && this.isSuperUser) { + await this.loadUsers() + } + this.receivableDialog.show = true + }, + async submitReceivable() { + this.receivableDialog.loading = true + try { + await LNbits.api.request( + 'POST', + '/castle/api/v1/entries/receivable', + this.g.user.wallets[0].adminkey, + { + description: this.receivableDialog.description, + amount: this.receivableDialog.amount, + revenue_account: this.receivableDialog.revenueAccount, + user_wallet: this.receivableDialog.selectedUser, + reference: this.receivableDialog.reference || null, + currency: this.receivableDialog.currency || null + } + ) + this.$q.notify({ + type: 'positive', + message: 'Receivable added successfully' + }) + this.receivableDialog.show = false + this.resetReceivableDialog() + await this.loadBalance() + await this.loadTransactions() + } catch (error) { + LNbits.utils.notifyApiError(error) + } finally { + this.receivableDialog.loading = false + } + }, resetExpenseDialog() { this.expenseDialog.description = '' this.expenseDialog.amount = null @@ -312,6 +391,14 @@ window.app = Vue.createApp({ this.expenseDialog.reference = '' this.expenseDialog.currency = null }, + resetReceivableDialog() { + this.receivableDialog.selectedUser = '' + this.receivableDialog.description = '' + this.receivableDialog.amount = null + this.receivableDialog.revenueAccount = '' + this.receivableDialog.reference = '' + this.receivableDialog.currency = null + }, formatSats(amount) { return new Intl.NumberFormat().format(amount) }, @@ -330,5 +417,9 @@ window.app = Vue.createApp({ await this.loadCurrencies() await this.loadSettings() await this.loadUserWallet() + // Load users if super user (for receivable dialog) + if (this.isSuperUser) { + await this.loadUsers() + } } }) diff --git a/templates/castle/index.html b/templates/castle/index.html index a798486..4127b7f 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -153,6 +153,20 @@ You must configure your wallet first + + Add Receivable + + Castle wallet must be configured first + + + Record when a user owes the Castle + + View Transactions @@ -410,4 +424,82 @@ + + + + +
Add Receivable
+ + + + + + + + + + + + + +
+ + Submit Receivable + + Cancel +
+
+
+
+ {% endblock %} diff --git a/views_api.py b/views_api.py index b8ace5b..682bc8f 100644 --- a/views_api.py +++ b/views_api.py @@ -20,6 +20,7 @@ from .crud import ( get_all_accounts, get_all_journal_entries, get_all_user_balances, + get_all_user_wallet_settings, get_journal_entries_by_user, get_journal_entry, get_or_create_user_account, @@ -282,7 +283,32 @@ async def api_create_receivable_entry( """ Create an accounts receivable entry (user owes castle). Admin only to prevent abuse. + + 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 revenue account revenue_account = await get_account_by_name(data.revenue_account) if not revenue_account: @@ -300,21 +326,24 @@ async def api_create_receivable_entry( # Create journal entry # DR Accounts Receivable (User), CR Revenue + 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=user_receivable.id, - debit=data.amount, + debit=amount_sats, credit=0, description=f"Amount owed by user {data.user_wallet[:8]}", + metadata=metadata, ), CreateEntryLine( account_id=revenue_account.id, debit=0, - credit=data.amount, + credit=amount_sats, description="Revenue earned", + metadata=metadata, ), ], ) @@ -558,6 +587,32 @@ async def api_update_settings( # ===== USER WALLET ENDPOINTS ===== +@castle_api_router.get("/api/v1/users") +async def api_get_all_users( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> list[dict]: + """Get all users who have configured their wallet (admin only)""" + from lnbits.core.crud.users import get_user + + user_settings = await get_all_user_wallet_settings() + + users = [] + for setting in user_settings: + # Get user details from core + user = await get_user(setting.id) + + # Use username if available, otherwise truncate user_id + username = user.username if user and user.username else setting.id[:16] + "..." + + users.append({ + "user_id": setting.id, + "user_wallet_id": setting.user_wallet_id, + "username": username, + }) + + return users + + @castle_api_router.get("/api/v1/user/wallet") async def api_get_user_wallet( user: User = Depends(check_user_exists),