1213 lines
37 KiB
Markdown
1213 lines
37 KiB
Markdown
# Castle Accounting Extension - Comprehensive Documentation
|
|
|
|
## Overview
|
|
|
|
The Castle Accounting extension for LNbits implements a double-entry bookkeeping system designed for cooperative/communal living spaces (like "castles"). It tracks financial relationships between a central entity (the Castle) and multiple users, handling both Lightning Network payments and manual/cash transactions.
|
|
|
|
## Architecture
|
|
|
|
### Core Accounting Concepts
|
|
|
|
The system implements traditional **double-entry bookkeeping** principles:
|
|
|
|
- Every transaction affects at least two accounts
|
|
- Debits must equal credits (balanced entries)
|
|
- Five account types: Assets, Liabilities, Equity, Revenue, Expenses
|
|
- Accounts have "normal balances" (debit or credit side)
|
|
|
|
### Account Types & Normal Balances
|
|
|
|
| Account Type | Normal Balance | Increases With | Decreases With | Purpose |
|
|
|--------------|----------------|----------------|----------------|---------|
|
|
| Asset | Debit | Debit | Credit | What Castle owns or is owed |
|
|
| Liability | Credit | Credit | Debit | What Castle owes to others |
|
|
| Equity | Credit | Credit | Debit | Member contributions, retained earnings |
|
|
| Revenue | Credit | Credit | Debit | Income earned by Castle |
|
|
| Expense | Debit | Debit | Credit | Costs incurred by Castle |
|
|
|
|
### User-Specific Accounts
|
|
|
|
The system creates **per-user accounts** for tracking individual balances:
|
|
|
|
- `Accounts Receivable - {user_id[:8]}` (Asset) - User owes Castle
|
|
- `Accounts Payable - {user_id[:8]}` (Liability) - Castle owes User
|
|
- `Member Equity - {user_id[:8]}` (Equity) - User's equity contributions
|
|
|
|
**Balance Interpretation:**
|
|
- `balance > 0` and account is Liability → Castle owes user (user is creditor)
|
|
- `balance < 0` (or positive Asset balance) → User owes Castle (user is debtor)
|
|
|
|
### Database Schema
|
|
|
|
#### Core Tables
|
|
|
|
**`accounts`**
|
|
```sql
|
|
CREATE TABLE accounts (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
account_type TEXT NOT NULL, -- asset, liability, equity, revenue, expense
|
|
description TEXT,
|
|
user_id TEXT, -- NULL for system accounts, user_id for user-specific accounts
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
```
|
|
|
|
**`journal_entries`**
|
|
```sql
|
|
CREATE TABLE journal_entries (
|
|
id TEXT PRIMARY KEY,
|
|
description TEXT NOT NULL,
|
|
entry_date TIMESTAMP NOT NULL,
|
|
created_by TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
reference TEXT -- Optional reference (e.g., payment_hash, invoice number)
|
|
);
|
|
```
|
|
|
|
**`entry_lines`**
|
|
```sql
|
|
CREATE TABLE entry_lines (
|
|
id TEXT PRIMARY KEY,
|
|
journal_entry_id TEXT NOT NULL,
|
|
account_id TEXT NOT NULL,
|
|
debit INTEGER NOT NULL DEFAULT 0, -- Amount in satoshis
|
|
credit INTEGER NOT NULL DEFAULT 0, -- Amount in satoshis
|
|
description TEXT,
|
|
metadata TEXT DEFAULT '{}' -- JSON: {fiat_currency, fiat_amount, fiat_rate, btc_rate}
|
|
);
|
|
```
|
|
|
|
**`extension_settings`**
|
|
```sql
|
|
CREATE TABLE extension_settings (
|
|
id TEXT NOT NULL PRIMARY KEY, -- Always "admin"
|
|
castle_wallet_id TEXT, -- LNbits wallet ID for Castle operations
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
```
|
|
|
|
**`user_wallet_settings`**
|
|
```sql
|
|
CREATE TABLE user_wallet_settings (
|
|
id TEXT NOT NULL PRIMARY KEY, -- user_id
|
|
user_wallet_id TEXT, -- User's LNbits wallet ID
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
```
|
|
|
|
**`manual_payment_requests`**
|
|
```sql
|
|
CREATE TABLE manual_payment_requests (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
amount INTEGER NOT NULL, -- Amount in satoshis
|
|
description TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, rejected
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
reviewed_at TIMESTAMP,
|
|
reviewed_by TEXT,
|
|
journal_entry_id TEXT -- Link to the journal entry when approved
|
|
);
|
|
```
|
|
|
|
### Metadata System
|
|
|
|
Each `entry_line` can store metadata as JSON to preserve original fiat amounts:
|
|
|
|
```json
|
|
{
|
|
"fiat_currency": "EUR",
|
|
"fiat_amount": 250.0,
|
|
"fiat_rate": 1074.192, // sats per fiat unit at time of transaction
|
|
"btc_rate": 0.000931 // fiat per sat at time of transaction
|
|
}
|
|
```
|
|
|
|
**Critical Design Decision:** Fiat balances are calculated by summing `fiat_amount` from metadata, NOT by converting current satoshi balances. This preserves historical accuracy and prevents exchange rate fluctuations from affecting accounting records.
|
|
|
|
## Transaction Flows
|
|
|
|
### 1. User Adds Expense (Liability Model)
|
|
|
|
**Use Case:** User pays for groceries with cash, Castle reimburses them
|
|
|
|
**User Action:** Add expense via UI
|
|
```javascript
|
|
POST /castle/api/v1/entries/expense
|
|
{
|
|
"description": "Biocoop groceries",
|
|
"amount": 36.93,
|
|
"currency": "EUR",
|
|
"expense_account": "food", // Account ID or name
|
|
"is_equity": false, // Creates liability, not equity
|
|
"user_wallet": "wallet_id",
|
|
"reference": null
|
|
}
|
|
```
|
|
|
|
**Journal Entry Created:**
|
|
```
|
|
Date: 2025-10-22
|
|
Description: Biocoop groceries (36.93 EUR)
|
|
|
|
DR Food & Supplies 39,669 sats
|
|
CR Accounts Payable - af983632 39,669 sats
|
|
|
|
Metadata on both lines:
|
|
{
|
|
"fiat_currency": "EUR",
|
|
"fiat_amount": 36.93,
|
|
"fiat_rate": 1074.192,
|
|
"btc_rate": 0.000931
|
|
}
|
|
```
|
|
|
|
**Effect:** Castle owes user €36.93 (39,669 sats)
|
|
|
|
### 2. Castle Adds Receivable
|
|
|
|
**Use Case:** User stays in a room, owes Castle for accommodation
|
|
|
|
**Castle Admin Action:** Add receivable via UI
|
|
```javascript
|
|
POST /castle/api/v1/entries/receivable
|
|
{
|
|
"description": "room 5 days",
|
|
"amount": 250.0,
|
|
"currency": "EUR",
|
|
"revenue_account": "accommodation_revenue",
|
|
"user_id": "af98363202614068...",
|
|
"reference": null
|
|
}
|
|
```
|
|
|
|
**Journal Entry Created:**
|
|
```
|
|
Date: 2025-10-22
|
|
Description: room 5 days (250.0 EUR)
|
|
|
|
DR Accounts Receivable - af983632 268,548 sats
|
|
CR Accommodation Revenue 268,548 sats
|
|
|
|
Metadata:
|
|
{
|
|
"fiat_currency": "EUR",
|
|
"fiat_amount": 250.0,
|
|
"fiat_rate": 1074.192,
|
|
"btc_rate": 0.000931
|
|
}
|
|
```
|
|
|
|
**Effect:** User owes Castle €250.00 (268,548 sats)
|
|
|
|
### 3. User Pays with Lightning
|
|
|
|
**User Action:** Click "Pay Balance" → Generate invoice → Pay
|
|
|
|
**Step A: Generate Invoice**
|
|
```javascript
|
|
POST /castle/api/v1/generate-payment-invoice
|
|
{
|
|
"amount": 268548
|
|
}
|
|
```
|
|
|
|
Returns:
|
|
```json
|
|
{
|
|
"payment_hash": "...",
|
|
"payment_request": "lnbc...",
|
|
"amount": 268548,
|
|
"memo": "Payment from user af983632 to Castle",
|
|
"check_wallet_key": "castle_wallet_inkey"
|
|
}
|
|
```
|
|
|
|
**Note:** Invoice is generated on **Castle's wallet**, not user's wallet. User polls using `check_wallet_key`.
|
|
|
|
**Step B: User Pays Invoice**
|
|
(External Lightning wallet or LNbits wallet)
|
|
|
|
**Step C: Record Payment**
|
|
```javascript
|
|
POST /castle/api/v1/record-payment
|
|
{
|
|
"payment_hash": "..."
|
|
}
|
|
```
|
|
|
|
**Journal Entry Created:**
|
|
```
|
|
Date: 2025-10-22
|
|
Description: Lightning payment from user af983632
|
|
Reference: payment_hash
|
|
|
|
DR Lightning Balance 268,548 sats
|
|
CR Accounts Receivable - af983632 268,548 sats
|
|
```
|
|
|
|
**Effect:** User's debt reduced by 268,548 sats
|
|
|
|
### 4. Manual Payment Request Flow
|
|
|
|
**Use Case:** User wants Castle to pay them in cash instead of Lightning
|
|
|
|
**Step A: User Requests Payment**
|
|
```javascript
|
|
POST /castle/api/v1/manual-payment-requests
|
|
{
|
|
"amount": 39669,
|
|
"description": "Please pay me in cash for groceries"
|
|
}
|
|
```
|
|
|
|
Creates `manual_payment_request` with status='pending'
|
|
|
|
**Step B: Castle Admin Reviews**
|
|
|
|
Admin sees pending request in UI:
|
|
- User: af983632
|
|
- Amount: 39,669 sats (€36.93)
|
|
- Description: "Please pay me in cash for groceries"
|
|
|
|
**Step C: Castle Admin Approves**
|
|
```javascript
|
|
POST /castle/api/v1/manual-payment-requests/{id}/approve
|
|
```
|
|
|
|
**Journal Entry Created:**
|
|
```
|
|
Date: 2025-10-22
|
|
Description: Manual payment to user af983632
|
|
Reference: manual_payment_request_id
|
|
|
|
DR Accounts Payable - af983632 39,669 sats
|
|
CR Lightning Balance 39,669 sats
|
|
```
|
|
|
|
**Effect:** Castle's liability to user reduced by 39,669 sats
|
|
|
|
**Alternative: Castle Admin Rejects**
|
|
```javascript
|
|
POST /castle/api/v1/manual-payment-requests/{id}/reject
|
|
```
|
|
No journal entry created, request marked as 'rejected'.
|
|
|
|
## Balance Calculation Logic
|
|
|
|
### User Balance Calculation
|
|
|
|
From `crud.py:get_user_balance()`:
|
|
|
|
```python
|
|
total_balance = 0
|
|
fiat_balances = {} # e.g., {"EUR": 250.0, "USD": 100.0}
|
|
|
|
for account in user_accounts:
|
|
account_balance = get_account_balance(account.id) # Sum of debits - credits (or vice versa)
|
|
|
|
# Calculate satoshi balance
|
|
if account.account_type == AccountType.LIABILITY:
|
|
total_balance += account_balance # Positive = Castle owes user
|
|
elif account.account_type == AccountType.ASSET:
|
|
total_balance -= account_balance # Positive asset = User owes Castle, so negative balance
|
|
|
|
# Calculate fiat balance from metadata
|
|
for line in account_entry_lines:
|
|
if line.metadata.fiat_currency and line.metadata.fiat_amount:
|
|
if account.account_type == AccountType.LIABILITY:
|
|
if line.credit > 0:
|
|
fiat_balances[currency] += fiat_amount # Castle owes more
|
|
elif line.debit > 0:
|
|
fiat_balances[currency] -= fiat_amount # Castle owes less
|
|
elif account.account_type == AccountType.ASSET:
|
|
if line.debit > 0:
|
|
fiat_balances[currency] -= fiat_amount # User owes more (negative balance)
|
|
elif line.credit > 0:
|
|
fiat_balances[currency] += fiat_amount # User owes less
|
|
```
|
|
|
|
**Result:**
|
|
- `balance > 0`: Castle owes user (LIABILITY side dominates)
|
|
- `balance < 0`: User owes Castle (ASSET side dominates)
|
|
- `fiat_balances`: Net fiat position per currency
|
|
|
|
### Castle Balance Calculation
|
|
|
|
From `views_api.py:api_get_my_balance()` (super user):
|
|
|
|
```python
|
|
all_balances = get_all_user_balances()
|
|
|
|
total_liabilities = sum(b.balance for b in all_balances if b.balance > 0) # What Castle owes
|
|
total_receivables = sum(abs(b.balance) for b in all_balances if b.balance < 0) # What is owed to Castle
|
|
net_balance = total_liabilities - total_receivables
|
|
|
|
# Aggregate all fiat balances
|
|
total_fiat_balances = {}
|
|
for user_balance in all_balances:
|
|
for currency, amount in user_balance.fiat_balances.items():
|
|
total_fiat_balances[currency] += amount
|
|
```
|
|
|
|
**Result:**
|
|
- `net_balance > 0`: Castle owes users (net liability)
|
|
- `net_balance < 0`: Users owe Castle (net receivable)
|
|
|
|
## UI/UX Design
|
|
|
|
### Perspective-Based Display
|
|
|
|
The UI adapts based on whether the viewer is a regular user or Castle admin (super user):
|
|
|
|
#### User View
|
|
|
|
**Balance Display:**
|
|
- Green text: Castle owes them (positive balance, incoming money)
|
|
- Red text: They owe Castle (negative balance, outgoing money)
|
|
|
|
**Transaction Badges:**
|
|
- Green "Receivable": Castle owes them (Accounts Payable entry)
|
|
- Red "Payable": They owe Castle (Accounts Receivable entry)
|
|
|
|
#### Castle Admin View (Super User)
|
|
|
|
**Balance Display:**
|
|
- Red text: Castle owes users (positive balance, outgoing money)
|
|
- Green text: Users owe Castle (negative balance, incoming money)
|
|
|
|
**Transaction Badges:**
|
|
- Green "Receivable": User owes Castle (Accounts Receivable entry)
|
|
- Red "Payable": Castle owes user (Accounts Payable entry)
|
|
|
|
**Outstanding Balances Table:**
|
|
Shows all users with non-zero balances:
|
|
- Username + truncated user_id
|
|
- Amount in sats and fiat
|
|
- "You owe" or "Owes you"
|
|
|
|
### Transaction List
|
|
|
|
Each user sees transactions that affect their accounts. The query uses:
|
|
|
|
```sql
|
|
SELECT DISTINCT je.*
|
|
FROM journal_entries je
|
|
JOIN entry_lines el ON je.id = el.journal_entry_id
|
|
WHERE el.account_id IN (user_account_ids)
|
|
ORDER BY je.entry_date DESC, je.created_at DESC
|
|
```
|
|
|
|
This ensures users see ALL transactions affecting them, not just ones they created.
|
|
|
|
## Default Chart of Accounts
|
|
|
|
Created by `m001_initial` migration:
|
|
|
|
### Assets
|
|
- `cash` - Cash on hand
|
|
- `bank` - Bank Account
|
|
- `lightning` - Lightning Balance
|
|
- `accounts_receivable` - Money owed to the Castle
|
|
|
|
### Liabilities
|
|
- `accounts_payable` - Money owed by the Castle
|
|
|
|
### Equity
|
|
- `member_equity` - Member contributions
|
|
- `retained_earnings` - Accumulated profits
|
|
|
|
### Revenue
|
|
- `accommodation_revenue` - Revenue from stays
|
|
- `service_revenue` - Revenue from services
|
|
- `other_revenue` - Other revenue
|
|
|
|
### Expenses
|
|
- `utilities` - Electricity, water, internet
|
|
- `food` - Food & Supplies
|
|
- `maintenance` - Repairs and maintenance
|
|
- `other_expense` - Miscellaneous expenses
|
|
|
|
## API Endpoints
|
|
|
|
### Account Management
|
|
- `GET /api/v1/accounts` - List all accounts
|
|
- `POST /api/v1/accounts` - Create new account (admin only)
|
|
- `GET /api/v1/accounts/{id}/balance` - Get account balance
|
|
- `GET /api/v1/accounts/{id}/transactions` - Get account transaction history
|
|
|
|
### Journal Entries
|
|
- `GET /api/v1/entries` - Get all journal entries (admin only)
|
|
- `GET /api/v1/entries/user` - Get current user's journal entries
|
|
- `GET /api/v1/entries/{id}` - Get specific journal entry
|
|
- `POST /api/v1/entries` - Create raw journal entry (admin only)
|
|
- `POST /api/v1/entries/expense` - Create expense entry (user)
|
|
- `POST /api/v1/entries/receivable` - Create receivable entry (admin only)
|
|
- `POST /api/v1/entries/revenue` - Create direct revenue entry (admin only)
|
|
|
|
### Balance & Payments
|
|
- `GET /api/v1/balance` - Get current user's balance (or Castle total if super user)
|
|
- `GET /api/v1/balance/{user_id}` - Get specific user's balance
|
|
- `GET /api/v1/balances/all` - Get all user balances (admin only, enriched with usernames)
|
|
- `POST /api/v1/generate-payment-invoice` - Generate invoice for user to pay Castle
|
|
- `POST /api/v1/record-payment` - Record Lightning payment to Castle
|
|
|
|
### Manual Payments
|
|
- `POST /api/v1/manual-payment-requests` - User creates manual payment request
|
|
- `GET /api/v1/manual-payment-requests` - User gets their manual payment requests
|
|
- `GET /api/v1/manual-payment-requests/all` - Admin gets all requests (optional status filter)
|
|
- `POST /api/v1/manual-payment-requests/{id}/approve` - Admin approves request
|
|
- `POST /api/v1/manual-payment-requests/{id}/reject` - Admin rejects request
|
|
|
|
### Settings
|
|
- `GET /api/v1/settings` - Get Castle settings (super user only)
|
|
- `PUT /api/v1/settings` - Update Castle settings (super user only)
|
|
- `GET /api/v1/user/wallet` - Get user's wallet settings
|
|
- `PUT /api/v1/user/wallet` - Update user's wallet settings
|
|
- `GET /api/v1/users` - Get all users with configured wallets (admin only)
|
|
|
|
### Utilities
|
|
- `GET /api/v1/currencies` - Get list of allowed fiat currencies
|
|
|
|
## Current Issues & Limitations
|
|
|
|
### 1. Critical Bug: User Account Creation Inconsistency
|
|
|
|
**Status:** FIXED in latest version
|
|
|
|
**Issue:** Expenses were creating user accounts with `wallet.wallet.id` (wallet ID) while receivables used `wallet.wallet.user` (user ID). This created duplicate accounts for the same user.
|
|
|
|
**Fix Applied:** Modified `api_create_expense_entry()` to use `wallet.wallet.user` consistently.
|
|
|
|
**Impact:** Users who added expenses before this fix will have orphaned accounts. Requires database reset or migration to consolidate.
|
|
|
|
### 2. No Account Merging/Consolidation Tool
|
|
|
|
**Issue:** If accounts were created with different user identifiers, there's no tool to merge them.
|
|
|
|
**Recommendation:** Build an admin tool to:
|
|
- Detect duplicate accounts for the same logical user
|
|
- Merge entry_lines from old account to new account
|
|
- Update balances
|
|
- Archive old account
|
|
|
|
### 3. No Audit Trail
|
|
|
|
**Issue:** No tracking of who modified what and when (beyond `created_by` on journal entries).
|
|
|
|
**Missing:**
|
|
- Edit history for journal entries
|
|
- Deletion tracking (currently no deletion support)
|
|
- Admin action logs
|
|
|
|
**Recommendation:** Add `audit_log` table:
|
|
```sql
|
|
CREATE TABLE audit_log (
|
|
id TEXT PRIMARY KEY,
|
|
timestamp TIMESTAMP NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
action TEXT NOT NULL, -- create, update, delete, approve, reject
|
|
entity_type TEXT NOT NULL, -- journal_entry, manual_payment_request, account
|
|
entity_id TEXT NOT NULL,
|
|
changes TEXT, -- JSON of before/after
|
|
ip_address TEXT
|
|
);
|
|
```
|
|
|
|
### 4. No Journal Entry Editing or Deletion
|
|
|
|
**Issue:** Once created, journal entries cannot be modified or deleted.
|
|
|
|
**Current Workaround:** Create reversing entries.
|
|
|
|
**Recommendation:** Implement:
|
|
- Soft delete (mark as void, create reversing entry)
|
|
- Amendment system (create new entry, link to original, mark original as amended)
|
|
- Strict permissions (only super user, only within N days of creation)
|
|
|
|
### 5. No Multi-Currency Support Beyond Metadata
|
|
|
|
**Issue:** System stores fiat amounts in metadata but doesn't support:
|
|
- Mixed-currency transactions
|
|
- Currency conversion at reporting time
|
|
- Multiple fiat currencies in same entry
|
|
|
|
**Current Limitation:** Each entry_line can have one fiat amount. If user pays €50 + $25, this requires two separate journal entries.
|
|
|
|
**Recommendation:** Consider:
|
|
- Multi-currency line items
|
|
- Exchange rate table
|
|
- Reporting in multiple currencies
|
|
|
|
### 6. No Equity Distribution Logic
|
|
|
|
**Issue:** System has `member_equity` accounts but no logic for:
|
|
- Profit/loss allocation to members
|
|
- Equity withdrawal requests
|
|
- Voting/governance based on equity
|
|
|
|
**Recommendation:** Add:
|
|
- Periodic close books process (allocate net income to member equity)
|
|
- Equity withdrawal workflow (similar to manual payment requests)
|
|
- Member equity statements
|
|
|
|
### 7. No Reconciliation Tools
|
|
|
|
**Issue:** No way to verify that:
|
|
- Lightning wallet balance matches accounting balance
|
|
- Debits = Credits across all entries
|
|
- No orphaned entry_lines
|
|
|
|
**Recommendation:** Add `/api/v1/reconcile` endpoint that checks:
|
|
```python
|
|
{
|
|
"lightning_wallet_balance": 1000000, # From LNbits
|
|
"lightning_account_balance": 1000000, # From accounting
|
|
"difference": 0,
|
|
"total_debits": 5000000,
|
|
"total_credits": 5000000,
|
|
"balanced": true,
|
|
"orphaned_lines": 0,
|
|
"issues": []
|
|
}
|
|
```
|
|
|
|
### 8. No Batch Operations
|
|
|
|
**Issue:** Adding 50 expenses requires 50 API calls.
|
|
|
|
**Recommendation:** Add:
|
|
- `POST /api/v1/entries/batch` - Create multiple entries in one call
|
|
- CSV import functionality
|
|
- Template system for recurring entries
|
|
|
|
### 9. No Date Range Filtering
|
|
|
|
**Issue:** Can't easily get "all transactions in October" or "Q1 revenue".
|
|
|
|
**Recommendation:** Add date range parameters to all list endpoints:
|
|
```
|
|
GET /api/v1/entries/user?start_date=2025-01-01&end_date=2025-03-31
|
|
```
|
|
|
|
### 10. No Reporting
|
|
|
|
**Issue:** No built-in reports for:
|
|
- Income statement (P&L)
|
|
- Balance sheet
|
|
- Cash flow statement
|
|
- Per-user statements
|
|
|
|
**Recommendation:** Add reporting endpoints:
|
|
- `GET /api/v1/reports/income-statement?start=...&end=...`
|
|
- `GET /api/v1/reports/balance-sheet?date=...`
|
|
- `GET /api/v1/reports/user-statement/{user_id}?start=...&end=...`
|
|
|
|
## Recommended Refactoring
|
|
|
|
### Phase 1: Data Integrity & Cleanup (High Priority)
|
|
|
|
1. **Account Consolidation Migration**
|
|
- Create `m005_consolidate_user_accounts.py`
|
|
- Detect accounts with wallet_id as user_id
|
|
- Migrate entry_lines to correct accounts
|
|
- Mark old accounts as archived
|
|
|
|
2. **Add Balance Validation**
|
|
- Add `GET /api/v1/validate` endpoint
|
|
- Check all journal entries balance (debits = credits)
|
|
- Check no orphaned entry_lines
|
|
- Check fiat balance calculation consistency
|
|
|
|
3. **Add Soft Delete Support**
|
|
- Add `deleted_at` column to all tables
|
|
- Add `void` status to journal_entries
|
|
- Create reversing entry when voiding
|
|
|
|
### Phase 2: Feature Completeness (Medium Priority)
|
|
|
|
4. **Audit Trail**
|
|
- Add `audit_log` table
|
|
- Log all creates, updates, deletes
|
|
- Add `/api/v1/audit-log` endpoint with filtering
|
|
|
|
5. **Reconciliation Tools**
|
|
- Add `/api/v1/reconcile` endpoint
|
|
- Add UI showing reconciliation status
|
|
- Alert when out of balance
|
|
|
|
6. **Reporting**
|
|
- Add income statement generator
|
|
- Add balance sheet generator
|
|
- Add user statement generator (PDF?)
|
|
- Add CSV export for all reports
|
|
|
|
7. **Date Range Filtering**
|
|
- Add `start_date`, `end_date` to all list endpoints
|
|
- Update UI to support date range selection
|
|
|
|
### Phase 3: Advanced Features (Low Priority)
|
|
|
|
8. **Batch Operations**
|
|
- Add batch entry creation
|
|
- Add CSV import
|
|
- Add recurring entry templates
|
|
|
|
9. **Multi-Currency Enhancement**
|
|
- Store exchange rates in database
|
|
- Support mixed-currency entries
|
|
- Add currency conversion at reporting time
|
|
|
|
10. **Equity Management**
|
|
- Add profit allocation logic
|
|
- Add equity withdrawal workflow
|
|
- Add member equity statements
|
|
|
|
### Phase 4: Export & Integration
|
|
|
|
11. **Beancount Export** (See next section)
|
|
|
|
## Beancount Export Strategy
|
|
|
|
[Beancount](https://beancount.github.io/) is a plain-text double-entry accounting system. Exporting to Beancount format enables:
|
|
- Professional-grade reporting
|
|
- Tax preparation
|
|
- External auditing
|
|
- Long-term archival
|
|
|
|
### Beancount File Format
|
|
|
|
```beancount
|
|
;; Account declarations
|
|
2025-01-01 open Assets:Lightning:Balance
|
|
2025-01-01 open Assets:AccountsReceivable:User-af983632
|
|
2025-01-01 open Liabilities:AccountsPayable:User-af983632
|
|
2025-01-01 open Equity:MemberEquity:User-af983632
|
|
2025-01-01 open Revenue:Accommodation
|
|
2025-01-01 open Expenses:Food
|
|
|
|
;; Transactions
|
|
2025-10-22 * "Biocoop groceries"
|
|
fiat-amount: "36.93 EUR"
|
|
fiat-rate: "1074.192 EUR/BTC"
|
|
Expenses:Food 39669 SATS
|
|
Liabilities:AccountsPayable:User-af983632 -39669 SATS
|
|
|
|
2025-10-22 * "room 5 days"
|
|
fiat-amount: "250.0 EUR"
|
|
fiat-rate: "1074.192 EUR/BTC"
|
|
Assets:AccountsReceivable:User-af983632 268548 SATS
|
|
Revenue:Accommodation -268548 SATS
|
|
|
|
2025-10-22 * "Lightning payment from user af983632"
|
|
payment-hash: "ffbdec55303..."
|
|
Assets:Lightning:Balance 268548 SATS
|
|
Assets:AccountsReceivable:User-af983632 -268548 SATS
|
|
```
|
|
|
|
### Export Implementation Plan
|
|
|
|
**Add Endpoint:**
|
|
```python
|
|
@castle_api_router.get("/api/v1/export/beancount")
|
|
async def export_beancount(
|
|
start_date: Optional[str] = None,
|
|
end_date: Optional[str] = None,
|
|
format: str = "text", # text or file
|
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
) -> Union[str, FileResponse]:
|
|
"""Export all accounting data to Beancount format"""
|
|
```
|
|
|
|
**Export Logic:**
|
|
|
|
1. **Generate Account Declarations**
|
|
```python
|
|
async def generate_beancount_accounts() -> list[str]:
|
|
accounts = await get_all_accounts()
|
|
lines = []
|
|
|
|
for account in accounts:
|
|
beancount_type = map_account_type(account.account_type)
|
|
beancount_name = format_account_name(account.name, account.user_id)
|
|
lines.append(f"2025-01-01 open {beancount_type}:{beancount_name}")
|
|
|
|
return lines
|
|
```
|
|
|
|
2. **Generate Transactions**
|
|
```python
|
|
async def generate_beancount_transactions(
|
|
start_date: Optional[datetime] = None,
|
|
end_date: Optional[datetime] = None
|
|
) -> list[str]:
|
|
entries = await get_all_journal_entries_filtered(start_date, end_date)
|
|
lines = []
|
|
|
|
for entry in entries:
|
|
# Header
|
|
date = entry.entry_date.strftime("%Y-%m-%d")
|
|
lines.append(f'{date} * "{entry.description}"')
|
|
|
|
# Add reference as metadata
|
|
if entry.reference:
|
|
lines.append(f' reference: "{entry.reference}"')
|
|
|
|
# Add fiat metadata if available
|
|
for line in entry.lines:
|
|
if line.metadata.get("fiat_currency"):
|
|
lines.append(f' fiat-amount: "{line.metadata["fiat_amount"]} {line.metadata["fiat_currency"]}"')
|
|
lines.append(f' fiat-rate: "{line.metadata["fiat_rate"]}"')
|
|
break
|
|
|
|
# Add entry lines
|
|
for line in entry.lines:
|
|
account = await get_account(line.account_id)
|
|
beancount_name = format_account_name(account.name, account.user_id)
|
|
beancount_type = map_account_type(account.account_type)
|
|
|
|
if line.debit > 0:
|
|
amount = line.debit
|
|
else:
|
|
amount = -line.credit
|
|
|
|
lines.append(f" {beancount_type}:{beancount_name} {amount} SATS")
|
|
|
|
lines.append("") # Blank line between transactions
|
|
|
|
return lines
|
|
```
|
|
|
|
3. **Helper Functions**
|
|
```python
|
|
def map_account_type(account_type: AccountType) -> str:
|
|
mapping = {
|
|
AccountType.ASSET: "Assets",
|
|
AccountType.LIABILITY: "Liabilities",
|
|
AccountType.EQUITY: "Equity",
|
|
AccountType.REVENUE: "Income", # Beancount uses "Income" not "Revenue"
|
|
AccountType.EXPENSE: "Expenses",
|
|
}
|
|
return mapping[account_type]
|
|
|
|
def format_account_name(name: str, user_id: Optional[str]) -> str:
|
|
# Convert "Accounts Receivable - af983632" to "AccountsReceivable:User-af983632"
|
|
# Convert "Food & Supplies" to "FoodAndSupplies"
|
|
name = name.replace(" - ", ":User-")
|
|
name = name.replace(" & ", "And")
|
|
name = name.replace(" ", "")
|
|
return name
|
|
```
|
|
|
|
4. **Add Custom Commodity (SATS)**
|
|
```python
|
|
def generate_commodity_declaration() -> str:
|
|
return """
|
|
2025-01-01 commodity SATS
|
|
name: "Satoshi"
|
|
asset-class: "cryptocurrency"
|
|
"""
|
|
```
|
|
|
|
**UI Addition:**
|
|
|
|
Add export button to Castle admin UI:
|
|
```html
|
|
<q-btn color="primary" @click="exportBeancount">
|
|
Export to Beancount
|
|
<q-tooltip>Download accounting data in Beancount format</q-tooltip>
|
|
</q-btn>
|
|
```
|
|
|
|
```javascript
|
|
async exportBeancount() {
|
|
try {
|
|
const response = await LNbits.api.request(
|
|
'GET',
|
|
'/castle/api/v1/export/beancount',
|
|
this.g.user.wallets[0].adminkey
|
|
)
|
|
|
|
// Create downloadable file
|
|
const blob = new Blob([response.data], { type: 'text/plain' })
|
|
const url = window.URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `castle-accounting-${new Date().toISOString().split('T')[0]}.beancount`
|
|
link.click()
|
|
window.URL.revokeObjectURL(url)
|
|
|
|
this.$q.notify({
|
|
type: 'positive',
|
|
message: 'Beancount file downloaded successfully'
|
|
})
|
|
} catch (error) {
|
|
LNbits.utils.notifyApiError(error)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Beancount Verification
|
|
|
|
After export, users can verify with Beancount:
|
|
|
|
```bash
|
|
# Check file is valid
|
|
bean-check castle-accounting-2025-10-22.beancount
|
|
|
|
# Generate reports
|
|
bean-report castle-accounting-2025-10-22.beancount balances
|
|
bean-report castle-accounting-2025-10-22.beancount income
|
|
bean-web castle-accounting-2025-10-22.beancount
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests Needed
|
|
|
|
1. **Balance Calculation**
|
|
- Test asset vs liability balance signs
|
|
- Test fiat balance aggregation
|
|
- Test mixed debit/credit entries
|
|
|
|
2. **Journal Entry Validation**
|
|
- Test debits = credits enforcement
|
|
- Test metadata preservation
|
|
- Test user account creation
|
|
|
|
3. **Transaction Flows**
|
|
- Test expense → payable flow
|
|
- Test receivable → payment flow
|
|
- Test manual payment approval flow
|
|
|
|
4. **Beancount Export**
|
|
- Test account name formatting
|
|
- Test transaction format
|
|
- Test metadata preservation
|
|
- Test debits = credits in output
|
|
|
|
### Integration Tests Needed
|
|
|
|
1. **End-to-End User Flow**
|
|
- User adds expense
|
|
- Castle adds receivable
|
|
- User pays via Lightning
|
|
- Verify balances at each step
|
|
|
|
2. **Manual Payment Flow**
|
|
- User requests payment
|
|
- Admin approves
|
|
- Verify journal entry created
|
|
- Verify balance updated
|
|
|
|
3. **Multi-User Scenarios**
|
|
- Multiple users with positive balances
|
|
- Multiple users with negative balances
|
|
- Verify Castle net balance calculation
|
|
|
|
## Security Considerations
|
|
|
|
### Current Implementation
|
|
|
|
1. **Super User Checks**
|
|
- Implemented as `wallet.wallet.user == lnbits_settings.super_user`
|
|
- Applied to: settings, receivables, manual payment approval/rejection, viewing all balances
|
|
|
|
2. **User Isolation**
|
|
- Users can only see their own balances and transactions
|
|
- Users cannot create receivables (only Castle admin can)
|
|
- Users cannot approve their own manual payment requests
|
|
|
|
3. **Wallet Key Requirements**
|
|
- `require_invoice_key`: Read access to user's data
|
|
- `require_admin_key`: Write access, Castle admin operations
|
|
|
|
### Potential Vulnerabilities
|
|
|
|
1. **No Rate Limiting**
|
|
- API endpoints have no rate limiting
|
|
- User could spam expense/payment requests
|
|
|
|
2. **No Input Validation Depth**
|
|
- Description fields accept arbitrary text (XSS risk in UI)
|
|
- Amount fields should have max limits
|
|
- Currency validation relies on exchange rate API
|
|
|
|
3. **No CSRF Protection**
|
|
- LNbits may handle this at framework level
|
|
- Verify with LNbits security docs
|
|
|
|
4. **Manual Payment Request Abuse**
|
|
- User could request payment for more than they're owed
|
|
- Recommendation: Add validation to check `amount <= user_balance`
|
|
|
|
### Recommendations
|
|
|
|
1. **Add Input Validation**
|
|
```python
|
|
class ExpenseEntry(BaseModel):
|
|
description: str = Field(..., max_length=500, min_length=1)
|
|
amount: float = Field(..., gt=0, le=1_000_000) # Max 1M sats or fiat
|
|
# ... etc
|
|
```
|
|
|
|
2. **Add Rate Limiting**
|
|
```python
|
|
from slowapi import Limiter
|
|
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
@limiter.limit("10/minute")
|
|
@castle_api_router.post("/api/v1/entries/expense")
|
|
async def api_create_expense_entry(...):
|
|
...
|
|
```
|
|
|
|
3. **Add Manual Payment Validation**
|
|
```python
|
|
async def create_manual_payment_request(user_id: str, amount: int, description: str):
|
|
# Check user's balance
|
|
balance = await get_user_balance(user_id)
|
|
if balance.balance <= 0:
|
|
raise ValueError("You have no positive balance to request payment for")
|
|
if amount > balance.balance:
|
|
raise ValueError(f"Requested amount exceeds your balance of {balance.balance} sats")
|
|
# ... proceed with creation
|
|
```
|
|
|
|
4. **Sanitize User Input**
|
|
- Escape HTML in descriptions before displaying
|
|
- Validate reference fields are alphanumeric only
|
|
|
|
## Performance Considerations
|
|
|
|
### Current Bottlenecks
|
|
|
|
1. **Balance Calculation**
|
|
- `get_user_balance()` iterates through all entry_lines for user's accounts
|
|
- `get_all_user_balances()` calls `get_user_balance()` for each user
|
|
- No caching
|
|
|
|
2. **Transaction List**
|
|
- Fetches all entry_lines for each journal_entry
|
|
- No pagination (hardcoded limit of 100)
|
|
|
|
3. **N+1 Query Problem**
|
|
- `get_journal_entries_by_user()` fetches entries, then calls `get_entry_lines()` for each
|
|
- Could be optimized with JOIN
|
|
|
|
### Optimization Recommendations
|
|
|
|
1. **Add Balance Cache**
|
|
```python
|
|
# New table
|
|
CREATE TABLE user_balance_cache (
|
|
user_id TEXT PRIMARY KEY,
|
|
balance INTEGER NOT NULL,
|
|
fiat_balances TEXT, -- JSON
|
|
last_updated TIMESTAMP NOT NULL
|
|
);
|
|
|
|
# Update cache after each transaction
|
|
async def update_balance_cache(user_id: str):
|
|
balance = await get_user_balance(user_id)
|
|
await db.execute(
|
|
"INSERT OR REPLACE INTO user_balance_cache ...",
|
|
...
|
|
)
|
|
```
|
|
|
|
2. **Add Pagination**
|
|
```python
|
|
@castle_api_router.get("/api/v1/entries/user")
|
|
async def api_get_user_entries(
|
|
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
|
limit: int = 100,
|
|
offset: int = 0, # Add offset
|
|
) -> dict:
|
|
entries = await get_journal_entries_by_user(
|
|
wallet.wallet.user, limit, offset
|
|
)
|
|
total = await count_journal_entries_by_user(wallet.wallet.user)
|
|
return {
|
|
"entries": entries,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
```
|
|
|
|
3. **Optimize Query with JOIN**
|
|
```python
|
|
async def get_journal_entries_by_user_optimized(user_id: str, limit: int = 100):
|
|
# Single query that fetches entries and their lines
|
|
rows = await db.fetchall(
|
|
"""
|
|
SELECT
|
|
je.id, je.description, je.entry_date, je.created_by,
|
|
je.created_at, je.reference,
|
|
el.id as line_id, el.account_id, el.debit, el.credit,
|
|
el.description as line_description, el.metadata
|
|
FROM journal_entries je
|
|
JOIN entry_lines el ON je.id = el.journal_entry_id
|
|
WHERE el.account_id IN (
|
|
SELECT id FROM accounts WHERE user_id = :user_id
|
|
)
|
|
ORDER BY je.entry_date DESC, je.created_at DESC
|
|
LIMIT :limit
|
|
""",
|
|
{"user_id": user_id, "limit": limit}
|
|
)
|
|
|
|
# Group by journal entry
|
|
entries_dict = {}
|
|
for row in rows:
|
|
if row["id"] not in entries_dict:
|
|
entries_dict[row["id"]] = JournalEntry(
|
|
id=row["id"],
|
|
description=row["description"],
|
|
entry_date=row["entry_date"],
|
|
created_by=row["created_by"],
|
|
created_at=row["created_at"],
|
|
reference=row["reference"],
|
|
lines=[]
|
|
)
|
|
entries_dict[row["id"]].lines.append(EntryLine(...))
|
|
|
|
return list(entries_dict.values())
|
|
```
|
|
|
|
4. **Add Database Indexes**
|
|
```sql
|
|
-- Already have these (good!)
|
|
CREATE INDEX idx_accounts_user_id ON accounts (user_id);
|
|
CREATE INDEX idx_entry_lines_account ON entry_lines (account_id);
|
|
CREATE INDEX idx_journal_entries_date ON journal_entries (entry_date);
|
|
|
|
-- Add these for optimization
|
|
CREATE INDEX idx_entry_lines_journal_and_account ON entry_lines (journal_entry_id, account_id);
|
|
CREATE INDEX idx_manual_payment_requests_user_status ON manual_payment_requests (user_id, status);
|
|
```
|
|
|
|
## Migration Path for Existing Data
|
|
|
|
If Castle is already in production with the old code:
|
|
|
|
### Migration Script: `m005_fix_user_accounts.py`
|
|
|
|
```python
|
|
async def m005_fix_user_accounts(db):
|
|
"""
|
|
Fix user accounts created with wallet_id instead of user_id.
|
|
Consolidate entry_lines to correct accounts.
|
|
"""
|
|
from lnbits.core.crud.wallets import get_wallet
|
|
|
|
# Get all accounts
|
|
accounts = await db.fetchall("SELECT * FROM accounts WHERE user_id IS NOT NULL")
|
|
|
|
# Group accounts by name prefix (e.g., "Accounts Receivable", "Accounts Payable")
|
|
wallet_id_accounts = [] # Accounts with wallet IDs
|
|
user_id_accounts = {} # Map: user_id -> account_id
|
|
|
|
for account in accounts:
|
|
if not account["user_id"]:
|
|
continue
|
|
|
|
# Check if user_id looks like a wallet_id (longer, different format)
|
|
# Wallet IDs are typically longer and contain different patterns
|
|
# We'll try to fetch a wallet with this ID
|
|
try:
|
|
wallet = await get_wallet(account["user_id"])
|
|
if wallet:
|
|
# This is a wallet_id, needs migration
|
|
wallet_id_accounts.append({
|
|
"account": account,
|
|
"wallet": wallet,
|
|
"actual_user_id": wallet.user
|
|
})
|
|
except:
|
|
# This is a proper user_id, keep as reference
|
|
user_id_accounts[account["user_id"]] = account["id"]
|
|
|
|
# For each wallet_id account, migrate to user_id account
|
|
for item in wallet_id_accounts:
|
|
old_account = item["account"]
|
|
actual_user_id = item["actual_user_id"]
|
|
|
|
# Find or create correct account
|
|
account_name_base = old_account["name"].split(" - ")[0] # e.g., "Accounts Receivable"
|
|
new_account_name = f"{account_name_base} - {actual_user_id[:8]}"
|
|
|
|
new_account = await db.fetchone(
|
|
"SELECT * FROM accounts WHERE name = :name AND user_id = :user_id",
|
|
{"name": new_account_name, "user_id": actual_user_id}
|
|
)
|
|
|
|
if not new_account:
|
|
# Create new account
|
|
await db.execute(
|
|
"""
|
|
INSERT INTO accounts (id, name, account_type, description, user_id, created_at)
|
|
VALUES (:id, :name, :type, :description, :user_id, :created_at)
|
|
""",
|
|
{
|
|
"id": urlsafe_short_hash(),
|
|
"name": new_account_name,
|
|
"type": old_account["account_type"],
|
|
"description": old_account["description"],
|
|
"user_id": actual_user_id,
|
|
"created_at": datetime.now()
|
|
}
|
|
)
|
|
new_account = await db.fetchone(
|
|
"SELECT * FROM accounts WHERE name = :name AND user_id = :user_id",
|
|
{"name": new_account_name, "user_id": actual_user_id}
|
|
)
|
|
|
|
# Migrate entry_lines
|
|
await db.execute(
|
|
"""
|
|
UPDATE entry_lines
|
|
SET account_id = :new_account_id
|
|
WHERE account_id = :old_account_id
|
|
""",
|
|
{"new_account_id": new_account["id"], "old_account_id": old_account["id"]}
|
|
)
|
|
|
|
# Mark old account as archived (add archived column first if needed)
|
|
await db.execute(
|
|
"DELETE FROM accounts WHERE id = :id",
|
|
{"id": old_account["id"]}
|
|
)
|
|
```
|
|
|
|
## Conclusion
|
|
|
|
The Castle Accounting extension provides a solid foundation for double-entry bookkeeping in LNbits. The core accounting logic is sound, with proper debit/credit handling and user-specific account isolation.
|
|
|
|
### Strengths
|
|
✅ Correct double-entry bookkeeping implementation
|
|
✅ User-specific account separation
|
|
✅ Metadata preservation for fiat amounts
|
|
✅ Lightning payment integration
|
|
✅ Manual payment workflow
|
|
✅ Perspective-based UI (user vs Castle view)
|
|
|
|
### Immediate Action Items
|
|
1. ✅ Fix user account creation bug (COMPLETED)
|
|
2. Deploy migration to consolidate existing accounts
|
|
3. Add balance cache for performance
|
|
4. Implement Beancount export
|
|
5. Add reconciliation endpoint
|
|
|
|
### Long-Term Goals
|
|
1. Full audit trail
|
|
2. Comprehensive reporting
|
|
3. Journal entry editing/voiding
|
|
4. Multi-currency support
|
|
5. Equity management features
|
|
6. External system integrations (accounting software, tax tools)
|
|
|
|
The refactoring path is clear: prioritize data integrity, then add reporting/export, then enhance with advanced features. The system is production-ready for basic use cases but needs the recommended enhancements for a full-featured cooperative accounting solution.
|