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