From 018a074915691458db108e746558b582f591563c Mon Sep 17 00:00:00 2001 From: padreug Date: Thu, 23 Oct 2025 00:26:52 +0200 Subject: [PATCH] Adds expense approval workflow Implements expense approval functionality, allowing superusers to review and approve or reject expense entries. This includes: - Filtering account balance calculations and user balance calculations to only include cleared journal entries. - Adding API endpoints to retrieve pending expense entries and approve/reject them. - Updating the UI to display pending expenses to superusers and provide actions to approve or reject them. This ensures better control over expenses within the system. --- crud.py | 22 ++++++-- static/js/index.js | 56 ++++++++++++++++++- templates/castle/index.html | 54 ++++++++++++++++++ views_api.py | 108 ++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+), 8 deletions(-) diff --git a/crud.py b/crud.py index 200a115..08287b8 100644 --- a/crud.py +++ b/crud.py @@ -313,14 +313,17 @@ async def get_journal_entries_by_user( async def get_account_balance(account_id: str) -> int: - """Calculate account balance (debits - credits for assets/expenses, credits - debits for liabilities/equity/revenue)""" + """Calculate account balance (debits - credits for assets/expenses, credits - debits for liabilities/equity/revenue) + Only includes entries that are cleared (flag='*'), excludes pending/flagged/voided entries.""" result = await db.fetchone( """ SELECT - COALESCE(SUM(debit), 0) as total_debit, - COALESCE(SUM(credit), 0) as total_credit - FROM entry_lines - WHERE account_id = :id + COALESCE(SUM(el.debit), 0) as total_debit, + COALESCE(SUM(el.credit), 0) as total_credit + FROM entry_lines el + JOIN journal_entries je ON el.journal_entry_id = je.id + WHERE el.account_id = :id + AND je.flag = '*' """, {"id": account_id}, ) @@ -360,8 +363,15 @@ async def get_user_balance(user_id: str) -> UserBalance: balance = await get_account_balance(account.id) # Get all entry lines for this account to calculate fiat balances + # Only include cleared entries (exclude pending/flagged/voided) entry_lines = await db.fetchall( - "SELECT * FROM entry_lines WHERE account_id = :account_id", + """ + SELECT el.* + FROM entry_lines el + JOIN journal_entries je ON el.journal_entry_id = je.id + WHERE el.account_id = :account_id + AND je.flag = '*' + """, {"account_id": account.id}, ) diff --git a/static/js/index.js b/static/js/index.js index d9ade1b..5cb4286 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -64,7 +64,8 @@ window.app = Vue.createApp({ description: '', loading: false }, - manualPaymentRequests: [] + manualPaymentRequests: [], + pendingExpenses: [] } }, watch: { @@ -493,6 +494,20 @@ window.app = Vue.createApp({ console.error('Error loading manual payment requests:', error) } }, + async loadPendingExpenses() { + try { + if (!this.isSuperUser) return + + const response = await LNbits.api.request( + 'GET', + '/castle/api/v1/entries/pending', + this.g.user.wallets[0].adminkey + ) + this.pendingExpenses = response.data + } catch (error) { + console.error('Error loading pending expenses:', error) + } + }, async approveManualPaymentRequest(requestId) { try { await LNbits.api.request( @@ -527,6 +542,42 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) } }, + async approveExpense(entryId) { + try { + await LNbits.api.request( + 'POST', + `/castle/api/v1/entries/${entryId}/approve`, + this.g.user.wallets[0].adminkey + ) + this.$q.notify({ + type: 'positive', + message: 'Expense approved!' + }) + await this.loadPendingExpenses() + await this.loadBalance() + await this.loadTransactions() + await this.loadAllUserBalances() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + async rejectExpense(entryId) { + try { + await LNbits.api.request( + 'POST', + `/castle/api/v1/entries/${entryId}/reject`, + this.g.user.wallets[0].adminkey + ) + this.$q.notify({ + type: 'warning', + message: 'Expense rejected' + }) + await this.loadPendingExpenses() + await this.loadTransactions() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, copyToClipboard(text) { navigator.clipboard.writeText(text) this.$q.notify({ @@ -674,9 +725,10 @@ window.app = Vue.createApp({ await this.loadAccounts() await this.loadCurrencies() await this.loadManualPaymentRequests() - // Load users if super user (for receivable dialog) + // Load users and pending expenses if super user if (this.isSuperUser) { await this.loadUsers() + await this.loadPendingExpenses() } } }) diff --git a/templates/castle/index.html b/templates/castle/index.html index 3d788fb..fb722ac 100644 --- a/templates/castle/index.html +++ b/templates/castle/index.html @@ -154,6 +154,60 @@ + + + +
Pending Expense Approvals
+ + + + + Pending approval + + + + {% raw %}{{ entry.description }}{% endraw %} + + {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} + + + User: {% raw %}{{ entry.meta.user_id.substring(0, 16) }}...{% endraw %} + + + Ref: {% raw %}{{ entry.reference }}{% endraw %} + + + + {% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %} + + {% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %} + + + +
+ + Approve + + + Reject + +
+
+
+
+
+
+ diff --git a/views_api.py b/views_api.py index b016f95..607d3f4 100644 --- a/views_api.py +++ b/views_api.py @@ -16,6 +16,7 @@ from .crud import ( create_account, create_journal_entry, create_manual_payment_request, + db, get_account, get_account_balance, get_account_by_name, @@ -281,6 +282,7 @@ async def api_create_expense_entry( entry_data = CreateJournalEntry( description=data.description + description_suffix, reference=data.reference, + flag=JournalEntryFlag.PENDING, # Expenses require admin approval meta=entry_meta, lines=[ CreateEntryLine( @@ -943,3 +945,109 @@ async def api_reject_manual_payment_request( ) return await reject_manual_payment_request(request_id, wallet.wallet.user) + + +# ===== EXPENSE APPROVAL ENDPOINTS ===== + + +@castle_api_router.get("/api/v1/entries/pending") +async def api_get_pending_entries( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> list[JournalEntry]: + """Get all pending expense entries that need approval (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can access this endpoint", + ) + + # Get all journal entries and filter for pending flag + all_entries = await get_all_journal_entries(limit=1000) + pending_entries = [e for e in all_entries if e.flag == JournalEntryFlag.PENDING] + return pending_entries + + +@castle_api_router.post("/api/v1/entries/{entry_id}/approve") +async def api_approve_expense_entry( + entry_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> JournalEntry: + """Approve a pending expense entry (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can approve expenses", + ) + + # Get the entry + entry = await get_journal_entry(entry_id) + if not entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Journal entry not found", + ) + + if entry.flag != JournalEntryFlag.PENDING: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Entry is not pending (current status: {entry.flag.value})", + ) + + # Update flag to cleared + await db.execute( + """ + UPDATE journal_entries + SET flag = :flag + WHERE id = :id + """, + {"flag": JournalEntryFlag.CLEARED.value, "id": entry_id} + ) + + # Return updated entry + return await get_journal_entry(entry_id) + + +@castle_api_router.post("/api/v1/entries/{entry_id}/reject") +async def api_reject_expense_entry( + entry_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> JournalEntry: + """Reject a pending expense entry (admin only)""" + from lnbits.settings import settings as lnbits_settings + + if wallet.wallet.user != lnbits_settings.super_user: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Only super user can reject expenses", + ) + + # Get the entry + entry = await get_journal_entry(entry_id) + if not entry: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Journal entry not found", + ) + + if entry.flag != JournalEntryFlag.PENDING: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Entry is not pending (current status: {entry.flag.value})", + ) + + # Update flag to voided + await db.execute( + """ + UPDATE journal_entries + SET flag = :flag + WHERE id = :id + """, + {"flag": JournalEntryFlag.VOID.value, "id": entry_id} + ) + + # Return updated entry + return await get_journal_entry(entry_id)