diff --git a/EXPENSE_APPROVAL.md b/EXPENSE_APPROVAL.md new file mode 100644 index 0000000..b8b3261 --- /dev/null +++ b/EXPENSE_APPROVAL.md @@ -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 diff --git a/crud.py b/crud.py index 08287b8..75b1fc4 100644 --- a/crud.py +++ b/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]: - entries = await db.fetchall( + entries_data = await db.fetchall( """ SELECT * FROM journal_entries ORDER BY entry_date DESC, created_at DESC 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) + entries.append(entry) return entries diff --git a/static/js/index.js b/static/js/index.js index 5cb4286..09d4904 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -497,6 +497,7 @@ window.app = Vue.createApp({ async loadPendingExpenses() { try { if (!this.isSuperUser) return + if (!this.g.user.wallets[0]?.adminkey) return const response = await LNbits.api.request( 'GET', diff --git a/views_api.py b/views_api.py index 607d3f4..f709d4a 100644 --- a/views_api.py +++ b/views_api.py @@ -178,6 +178,25 @@ async def api_get_user_entries( 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}") async def api_get_journal_entry(entry_id: str) -> JournalEntry: """Get a specific journal entry""" @@ -950,25 +969,6 @@ async def api_reject_manual_payment_request( # ===== 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,