Implements expense approval workflow
Adds an admin approval workflow for user-submitted expenses. This ensures that only valid expenses affect user balances. The workflow includes pending expense states, admin approval/rejection actions, balance filtering, and UI updates.
This commit is contained in:
parent
018a074915
commit
3b371e3bec
4 changed files with 207 additions and 22 deletions
167
EXPENSE_APPROVAL.md
Normal file
167
EXPENSE_APPROVAL.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# Expense Approval Workflow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Castle extension now requires admin approval for all user-submitted expenses. This prevents invalid or incorrect expenses from affecting balances until they are verified by the Castle admin.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### 1. User Submits Expense
|
||||||
|
- User fills out the expense form with description, amount, category, etc.
|
||||||
|
- Expense is created with `flag='!'` (PENDING status)
|
||||||
|
- Entry is saved to the database but **does not affect balances**
|
||||||
|
|
||||||
|
### 2. Admin Reviews Pending Expenses
|
||||||
|
- Admin sees "Pending Expense Approvals" card on the main page
|
||||||
|
- Card shows all pending expenses with:
|
||||||
|
- Description and amount
|
||||||
|
- User who submitted it
|
||||||
|
- Date submitted
|
||||||
|
- Fiat amount (if applicable)
|
||||||
|
- Reference number
|
||||||
|
|
||||||
|
### 3. Admin Takes Action
|
||||||
|
|
||||||
|
#### Option A: Approve
|
||||||
|
- Admin clicks "Approve" button
|
||||||
|
- Entry flag changes from `!` to `*` (CLEARED)
|
||||||
|
- Entry **now affects balances** (user's balance updates)
|
||||||
|
- User sees the expense in their transaction history
|
||||||
|
- Entry appears with green checkmark icon
|
||||||
|
|
||||||
|
#### Option B: Reject
|
||||||
|
- Admin clicks "Reject" button
|
||||||
|
- Entry flag changes from `!` to `x` (VOID)
|
||||||
|
- Entry **never affects balances**
|
||||||
|
- Entry appears with grey cancel icon (voided)
|
||||||
|
|
||||||
|
## Balance Calculation
|
||||||
|
|
||||||
|
Only entries with `flag='*'` (CLEARED) are included in balance calculations:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Balance query excludes pending/flagged/voided entries
|
||||||
|
SELECT SUM(debit), SUM(credit)
|
||||||
|
FROM entry_lines el
|
||||||
|
JOIN journal_entries je ON el.journal_entry_id = je.id
|
||||||
|
WHERE el.account_id = :account_id
|
||||||
|
AND je.flag = '*' -- Only cleared entries
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transaction Flags
|
||||||
|
|
||||||
|
| Flag | Symbol | Status | Affects Balance | Description |
|
||||||
|
|------|--------|--------|----------------|-------------|
|
||||||
|
| `*` | ✅ | CLEARED | Yes | Confirmed and reconciled |
|
||||||
|
| `!` | ⏱️ | PENDING | No | Awaiting approval |
|
||||||
|
| `#` | 🚩 | FLAGGED | No | Needs review |
|
||||||
|
| `x` | ❌ | VOID | No | Cancelled/rejected |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Get Pending Entries (Admin Only)
|
||||||
|
```
|
||||||
|
GET /castle/api/v1/entries/pending
|
||||||
|
Authorization: Admin Key
|
||||||
|
|
||||||
|
Returns: list[JournalEntry]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Approve Expense (Admin Only)
|
||||||
|
```
|
||||||
|
POST /castle/api/v1/entries/{entry_id}/approve
|
||||||
|
Authorization: Admin Key
|
||||||
|
|
||||||
|
Returns: JournalEntry (with flag='*')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reject Expense (Admin Only)
|
||||||
|
```
|
||||||
|
POST /castle/api/v1/entries/{entry_id}/reject
|
||||||
|
Authorization: Admin Key
|
||||||
|
|
||||||
|
Returns: JournalEntry (with flag='x')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
1. **views_api.py**
|
||||||
|
- Line 284: Set expenses to `JournalEntryFlag.PENDING` on creation
|
||||||
|
- Lines 181-197: Added `/api/v1/entries/pending` endpoint
|
||||||
|
- Lines 972-1011: Added approve endpoint
|
||||||
|
- Lines 1013-1053: Added reject endpoint
|
||||||
|
|
||||||
|
2. **crud.py**
|
||||||
|
- Lines 315-329: Updated `get_account_balance()` to filter by flag
|
||||||
|
- Lines 367-376: Updated fiat balance calculation to filter by flag
|
||||||
|
- Lines 238-269: Fixed `get_all_journal_entries()` to parse flag/meta
|
||||||
|
|
||||||
|
3. **index.html**
|
||||||
|
- Lines 157-209: Added "Pending Expense Approvals" card
|
||||||
|
|
||||||
|
4. **index.js**
|
||||||
|
- Line 68: Added `pendingExpenses` data property
|
||||||
|
- Lines 497-511: Added `loadPendingExpenses()` method
|
||||||
|
- Lines 545-563: Added `approveExpense()` method
|
||||||
|
- Lines 564-580: Added `rejectExpense()` method
|
||||||
|
- Line 731: Load pending expenses on page load for admins
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### For Regular Users
|
||||||
|
1. Submit expense via "Add Expense" button
|
||||||
|
2. See expense with orange pending icon (⏱️) in transaction list
|
||||||
|
3. Balance does NOT change yet
|
||||||
|
4. Wait for admin approval
|
||||||
|
|
||||||
|
### For Admin (Super User)
|
||||||
|
1. See "Pending Expense Approvals" card at top of page
|
||||||
|
2. Review expense details
|
||||||
|
3. Click "Approve" → User's balance updates
|
||||||
|
4. Click "Reject" → Expense is voided, no balance change
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All approval endpoints require admin key
|
||||||
|
- Super user check prevents regular users from approving their own expenses
|
||||||
|
- Voided entries are never included in balance calculations
|
||||||
|
- Full audit trail in `meta` field tracks who created and reviewed each entry
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
1. **Submit test expense as regular user**
|
||||||
|
```
|
||||||
|
POST /castle/api/v1/entries/expense
|
||||||
|
{
|
||||||
|
"description": "Test groceries",
|
||||||
|
"amount": 50.00,
|
||||||
|
"currency": "EUR",
|
||||||
|
"expense_account": "utilities",
|
||||||
|
"is_equity": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify it's pending**
|
||||||
|
- Check user balance → should NOT include this expense
|
||||||
|
- Check transaction list → should show orange pending icon
|
||||||
|
|
||||||
|
3. **Login as admin and approve**
|
||||||
|
- See expense in "Pending Expense Approvals"
|
||||||
|
- Click "Approve"
|
||||||
|
- Verify user balance updates
|
||||||
|
|
||||||
|
4. **Submit another expense and reject it**
|
||||||
|
- Submit expense
|
||||||
|
- Admin clicks "Reject"
|
||||||
|
- Verify balance never changed
|
||||||
|
- Entry shows grey cancel icon
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Email notifications when expense is approved/rejected
|
||||||
|
- [ ] Bulk approve/reject multiple expenses
|
||||||
|
- [ ] Admin notes when rejecting (reason for rejection)
|
||||||
|
- [ ] Expense revision system (user can edit and resubmit rejected expenses)
|
||||||
|
- [ ] Approval workflow with multiple approvers
|
||||||
23
crud.py
23
crud.py
|
|
@ -236,18 +236,35 @@ async def get_entry_lines(journal_entry_id: str) -> list[EntryLine]:
|
||||||
|
|
||||||
|
|
||||||
async def get_all_journal_entries(limit: int = 100) -> list[JournalEntry]:
|
async def get_all_journal_entries(limit: int = 100) -> list[JournalEntry]:
|
||||||
entries = await db.fetchall(
|
entries_data = await db.fetchall(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM journal_entries
|
SELECT * FROM journal_entries
|
||||||
ORDER BY entry_date DESC, created_at DESC
|
ORDER BY entry_date DESC, created_at DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""",
|
""",
|
||||||
{"limit": limit},
|
{"limit": limit},
|
||||||
JournalEntry,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for entry in entries:
|
entries = []
|
||||||
|
for entry_data in entries_data:
|
||||||
|
# Parse flag and meta from database
|
||||||
|
from .models import JournalEntryFlag
|
||||||
|
flag = JournalEntryFlag(entry_data.get("flag", "*"))
|
||||||
|
meta = json.loads(entry_data.get("meta", "{}")) if entry_data.get("meta") else {}
|
||||||
|
|
||||||
|
entry = JournalEntry(
|
||||||
|
id=entry_data["id"],
|
||||||
|
description=entry_data["description"],
|
||||||
|
entry_date=entry_data["entry_date"],
|
||||||
|
created_by=entry_data["created_by"],
|
||||||
|
created_at=entry_data["created_at"],
|
||||||
|
reference=entry_data["reference"],
|
||||||
|
flag=flag,
|
||||||
|
meta=meta,
|
||||||
|
lines=[],
|
||||||
|
)
|
||||||
entry.lines = await get_entry_lines(entry.id)
|
entry.lines = await get_entry_lines(entry.id)
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -497,6 +497,7 @@ window.app = Vue.createApp({
|
||||||
async loadPendingExpenses() {
|
async loadPendingExpenses() {
|
||||||
try {
|
try {
|
||||||
if (!this.isSuperUser) return
|
if (!this.isSuperUser) return
|
||||||
|
if (!this.g.user.wallets[0]?.adminkey) return
|
||||||
|
|
||||||
const response = await LNbits.api.request(
|
const response = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
|
|
|
||||||
38
views_api.py
38
views_api.py
|
|
@ -178,6 +178,25 @@ async def api_get_user_entries(
|
||||||
return await get_journal_entries_by_user(wallet.wallet.user, limit)
|
return await get_journal_entries_by_user(wallet.wallet.user, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@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.get("/api/v1/entries/{entry_id}")
|
@castle_api_router.get("/api/v1/entries/{entry_id}")
|
||||||
async def api_get_journal_entry(entry_id: str) -> JournalEntry:
|
async def api_get_journal_entry(entry_id: str) -> JournalEntry:
|
||||||
"""Get a specific journal entry"""
|
"""Get a specific journal entry"""
|
||||||
|
|
@ -950,25 +969,6 @@ async def api_reject_manual_payment_request(
|
||||||
# ===== EXPENSE APPROVAL ENDPOINTS =====
|
# ===== 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")
|
@castle_api_router.post("/api/v1/entries/{entry_id}/approve")
|
||||||
async def api_approve_expense_entry(
|
async def api_approve_expense_entry(
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue