castle/docs/DOCUMENTATION.md
2025-11-04 01:19:30 +01:00

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.