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.
This commit is contained in:
parent
8221feec20
commit
018a074915
4 changed files with 232 additions and 8 deletions
22
crud.py
22
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},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -154,6 +154,60 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Pending Expense Entries (Super User Only) -->
|
||||
<q-card v-if="isSuperUser && pendingExpenses.length > 0">
|
||||
<q-card-section>
|
||||
<h6 class="q-my-none q-mb-md">Pending Expense Approvals</h6>
|
||||
<q-list separator>
|
||||
<q-item v-for="entry in pendingExpenses" :key="entry.id">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="pending" color="orange" size="sm">
|
||||
<q-tooltip>Pending approval</q-tooltip>
|
||||
</q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{% raw %}{{ entry.description }}{% endraw %}</q-item-label>
|
||||
<q-item-label caption>
|
||||
{% raw %}{{ formatDate(entry.entry_date) }}{% endraw %}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="entry.meta && entry.meta.user_id">
|
||||
User: {% raw %}{{ entry.meta.user_id.substring(0, 16) }}...{% endraw %}
|
||||
</q-item-label>
|
||||
<q-item-label caption v-if="entry.reference" class="text-grey">
|
||||
Ref: {% raw %}{{ entry.reference }}{% endraw %}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label>{% raw %}{{ formatSats(getTotalAmount(entry)) }} sats{% endraw %}</q-item-label>
|
||||
<q-item-label caption v-if="getEntryFiatAmount(entry)">
|
||||
{% raw %}{{ getEntryFiatAmount(entry) }}{% endraw %}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div class="q-gutter-xs">
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="positive"
|
||||
@click="approveExpense(entry.id)"
|
||||
:loading="entry.approving"
|
||||
>
|
||||
Approve
|
||||
</q-btn>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="negative"
|
||||
@click="rejectExpense(entry.id)"
|
||||
:loading="entry.rejecting"
|
||||
>
|
||||
Reject
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Pending Manual Payment Requests (Super User Only) -->
|
||||
<q-card v-if="isSuperUser && pendingManualPaymentRequests.length > 0">
|
||||
<q-card-section>
|
||||
|
|
|
|||
108
views_api.py
108
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue