15 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Castle Accounting is a double-entry bookkeeping extension for LNbits that enables collectives (co-living spaces, makerspaces, community projects) to track finances with proper accounting principles. It integrates Lightning Network payments with traditional accounting, supporting both cryptocurrency and fiat currency tracking.
Architecture
Core Design Principles
Double-Entry Accounting: Every transaction affects at least two accounts. Debits must equal credits. Five account types: Assets, Liabilities, Equity, Revenue (Income), Expenses.
Fava/Beancount Backend: Castle now uses Fava as the primary accounting engine. Fava is a web interface for Beancount that provides a REST API for ledger operations. All accounting calculations (balance sheets, trial balances, account reports) are delegated to Fava/Beancount. Castle formats transactions as Beancount entries and submits them via Fava's API.
Required External Dependency: Fava must be running as a separate service. Configure fava_url and fava_ledger_slug in Castle settings (default: http://localhost:3333 with slug castle-accounting). Castle will not function without Fava.
Pure Functional Core: The core/ directory contains pure accounting logic independent of the database layer:
core/validation.py- Entry validation rules
Account Hierarchy: Beancount-style hierarchical naming with : separators:
Assets:Lightning:BalanceAssets:Receivable:User-af983632Liabilities:Payable:User-af983632Expenses:Food:Supplies
Amount Format: Recent architecture change uses string-based amounts with currency codes:
- SATS amounts:
"200000 SATS" - Fiat amounts:
"100.00 EUR"or"250.00 USD" - Cost basis notation:
"200000 SATS {100.00 EUR}"(200k sats acquired at 100 EUR) - Parsing handles both formats via
parse_amount_string()in views_api.py
Metadata System: Beancount metadata format stores original fiat amounts and exchange rates as key-value pairs. Critical: fiat balances are calculated by summing fiat amounts from journal entries, NOT by converting current satoshi balances. This prevents exchange rate fluctuations from affecting historical records.
Key Files
models.py- Pydantic models for API I/O and data structurescrud.py- Database operations (create/read/update accounts, journal entries)views_api.py- FastAPI endpoints for all operationsviews.py- Web interface routingservices.py- Settings management layermigrations.py- Database schema migrationstasks.py- Background tasks (invoice payment monitoring)account_utils.py- Hierarchical account naming utilitiesfava_client.py- HTTP client for Fava REST API (add_entry, query, balance_sheet)beancount_format.py- Converts Castle entries to Beancount transaction formatcore/validation.py- Pure validation functions for accounting rules
Database Schema
Note: With Fava integration, Castle maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava.
journal_entries: Transaction headers stored locally and synced to Fava
flagfield:*(cleared),!(pending),#(flagged),x(void)metafield: JSON storing source, tags, audit inforeferencefield: Links to payment_hash, invoice numbers, etc.- Enriched with
usernamefield when retrieved via API (added from LNbits user data)
extension_settings: Castle wallet configuration (admin-only)
castle_wallet_id- The LNbits wallet used for Castle operationsfava_url- Fava service URL (default: http://localhost:3333)fava_ledger_slug- Ledger identifier in Fava (default: castle-accounting)fava_timeout- API request timeout in seconds
user_wallet_settings: Per-user wallet configuration
manual_payment_requests: User requests for cash/manual payments
Transaction Flows
User Adds Expense (Liability)
User pays cash for groceries, Castle owes them:
DR Expenses:Food 39,669 sats
CR Liabilities:Payable:User-af983632 39,669 sats
Metadata preserves: {"fiat_currency": "EUR", "fiat_amount": "36.93", "fiat_rate": "1074.192"}
Castle Adds Receivable
User owes Castle for accommodation:
DR Assets:Receivable:User-af983632 268,548 sats
CR Income:Accommodation 268,548 sats
User Pays with Lightning
Invoice generated on Castle's wallet (not user's). After payment:
DR Assets:Lightning:Balance 268,548 sats
CR Assets:Receivable:User-af983632 268,548 sats
Manual Payment Approval
User requests cash payment → Admin approves → Journal entry created:
DR Liabilities:Payable:User-af983632 39,669 sats
CR Assets:Lightning:Balance 39,669 sats
Balance Calculation Logic
User Balance (calculated by Beancount via Fava):
- Positive = Castle owes user (LIABILITY accounts have credit balance)
- Negative = User owes Castle (ASSET accounts have debit balance)
- Calculated by querying Fava for sum of all postings across user's accounts
- Fiat balances calculated by Beancount from cost basis annotations, NOT converted from current sats
Perspective-Based UI:
- User View: Green = Castle owes them, Red = They owe Castle
- Castle Admin View: Green = User owes Castle, Red = Castle owes user
Balance Retrieval: Use GET /api/v1/balance which queries Fava's balance sheet or account reports for accurate, Beancount-calculated balances.
API Endpoints
Accounts
GET /api/v1/accounts- List all accountsPOST /api/v1/accounts- Create account (admin)GET /api/v1/accounts/{id}/balance- Get account balance
Journal Entries
POST /api/v1/entries/expense- User adds expense (creates liability or equity)POST /api/v1/entries/receivable- Admin records what user owes (admin only)POST /api/v1/entries/revenue- Admin records direct revenue (admin only)GET /api/v1/entries/user- Get user's journal entriesPOST /api/v1/entries- Create raw journal entry (admin only)
Payments & Balances
GET /api/v1/balance- Get user balance (or Castle total if super user)GET /api/v1/balances/all- Get all user balances (admin, enriched with usernames)POST /api/v1/generate-payment-invoice- Generate invoice for user to pay CastlePOST /api/v1/record-payment- Record Lightning payment from user to CastlePOST /api/v1/settle-receivable- Manually settle receivable (cash/bank)POST /api/v1/pay-user- Castle pays user (cash/bank/lightning)
Manual Payment Requests
POST /api/v1/manual-payment-requests- User requests paymentGET /api/v1/manual-payment-requests- User's requestsGET /api/v1/manual-payment-requests/all- All requests (admin)POST /api/v1/manual-payment-requests/{id}/approve- Approve (admin)POST /api/v1/manual-payment-requests/{id}/reject- Reject (admin)
Reconciliation
POST /api/v1/assertions/balance- Create balance assertionGET /api/v1/assertions/balance- List balance assertionsPOST /api/v1/assertions/balance/{id}/check- Check assertionPOST /api/v1/tasks/daily-reconciliation- Run daily reconciliation (admin)
Settings
GET /api/v1/settings- Get Castle settings (super user)PUT /api/v1/settings- Update Castle settings (super user)GET /api/v1/user/wallet- Get user wallet settingsPUT /api/v1/user/wallet- Update user wallet settings
Development Notes
Testing Entry Creation
When creating journal entries programmatically, use the helper endpoints:
POST /api/v1/entries/expensefor user expenses (handles account creation automatically)POST /api/v1/entries/receivablefor what users owePOST /api/v1/entries/revenuefor direct revenue
For custom entries, use POST /api/v1/entries with properly balanced lines.
User Account Management
User-specific accounts are created automatically with format:
- Assets:
Assets:Receivable:User-{user_id[:8]} - Liabilities:
Liabilities:Payable:User-{user_id[:8]} - Equity:
Equity:MemberEquity:User-{user_id[:8]}
Use get_or_create_user_account() in crud.py to ensure consistency.
Currency Handling
CRITICAL: Use Decimal for all fiat amounts, never float.
New Amount String Format (recent architecture change):
- Input format:
"100.00 EUR"or"200000 SATS" - Cost basis format:
"200000 SATS {100.00 EUR}"(for recording acquisition cost) - Parse using
parse_amount_string(amount_str)in views_api.py - Returns tuple:
(amount: Decimal, currency: str, cost_basis: Optional[tuple])
Beancount Metadata Format:
# Metadata attached to individual postings (legs of a transaction)
metadata = {
"fiat_currency": "EUR",
"fiat_amount": "250.00", # String for precision
"fiat_rate": "1074.192", # Sats per fiat unit
}
Important: When creating entries to submit to Fava, use beancount_format.format_transaction() to ensure proper Beancount syntax.
Fava Integration Patterns
Adding a Transaction:
from .fava_client import get_fava_client
from .beancount_format import format_transaction
from datetime import date
# Format as Beancount transaction
entry = format_transaction(
date_val=date.today(),
flag="*",
narration="Groceries purchase",
postings=[
{"account": "Expenses:Food", "amount": "50000 SATS {46.50 EUR}"},
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
],
tags=["groceries"],
links=["castle-entry-123"]
)
# Submit to Fava
client = get_fava_client()
result = await client.add_entry(entry)
Querying Balances:
# Query user balance from Fava
balance_result = await client.query(
f"SELECT sum(position) WHERE account ~ 'User-{user_id_short}'"
)
Important: Always use sanitize_link() from beancount_format.py when creating links to ensure Beancount compatibility (only A-Z, a-z, 0-9, -, _, /, . allowed).
Permission Model
- Super User: Full access (check via
wallet.wallet.user == lnbits_settings.super_user) - Admin Key: Required for creating receivables, approving payments, viewing all balances
- Invoice Key: Read access to user's own data
- Users: Can only see/manage their own accounts and transactions
Extension as LNbits Module
This extension follows LNbits extension structure:
- Registered via
castle_extrouter in__init__.py - Static files served from
static/directory - Templates in
templates/castle/ - Database accessed via
db = Database("ext_castle")
Startup Requirements:
castle_start()initializes Fava client on extension load- Background task
wait_for_paid_invoices()monitors Lightning invoice payments - Fava service MUST be running before starting LNbits with Castle extension
Common Tasks
Add New Account in Fava
from .fava_client import get_fava_client
from datetime import date
# Create Open directive for new account
client = get_fava_client()
entry = {
"t": "Open",
"date": str(date.today()),
"account": "Expenses:Internet",
"currencies": ["SATS", "EUR"]
}
await client.add_entry(entry)
Record Transaction to Fava
from .beancount_format import format_transaction
entry = format_transaction(
date_val=date.today(),
flag="*",
narration="Internet bill payment",
postings=[
{"account": "Expenses:Internet", "amount": "50000 SATS {46.50 EUR}"},
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
],
tags=["utilities"],
links=["castle-tx-123"]
)
client = get_fava_client()
await client.add_entry(entry)
Query User Balance from Fava
client = get_fava_client()
# Query all accounts for a user
user_short = user_id[:8]
query = f"SELECT account, sum(position) WHERE account ~ 'User-{user_short}' GROUP BY account"
result = await client.query(query)
# Parse result to calculate net balance
# (sum of all user accounts across Assets, Liabilities, Equity)
Data Integrity
Critical Invariants:
- Every transaction submitted to Fava MUST have balanced debits and credits (Beancount enforces this)
- Fiat amounts tracked via cost basis notation:
"AMOUNT SATS {COST FIAT}" - User accounts use
user_id(NOTwallet_id) for consistency - All accounting calculations delegated to Beancount/Fava
Validation is performed in core/validation.py:
- Pure validation functions for entry correctness before submitting to Fava
Beancount String Sanitization:
- Links must match pattern:
[A-Za-z0-9\-_/.] - Use
sanitize_link()from beancount_format.py for all links and tags
Recent Architecture Changes
Migration to Fava/Beancount (2025):
- Removed local balance calculation logic (now handled by Beancount)
- Removed local
accountsandentry_linestables (Fava is source of truth) - Added
fava_client.pyandbeancount_format.pymodules - Changed amount format to string-based with currency codes
- Username enrichment added to journal entries for UI display
Key Breaking Changes:
- All balance queries now go through Fava API
- Account creation must use Fava's Open directive
- Transaction format must follow Beancount syntax
- Cost basis notation required for multi-currency tracking
Development Setup
Prerequisites
- LNbits: This extension must be installed in the
lnbits/extensions/directory - Fava Service: Must be running before starting LNbits with Castle enabled
# Install Fava pip install fava # Create a basic Beancount file touch castle-ledger.beancount # Start Fava (default: http://localhost:3333) fava castle-ledger.beancount - Configure Castle Settings: Set
fava_urlandfava_ledger_slugvia settings API or UI
Running Castle Extension
Castle is loaded as part of LNbits. No separate build or test commands are needed for the extension itself. Development workflow:
- Modify code in
lnbits/extensions/castle/ - Restart LNbits
- Extension hot-reloads are supported by LNbits in development mode
Testing Transactions
Use the web UI or API endpoints to create test transactions. For API testing:
# Create expense (user owes Castle)
curl -X POST http://localhost:5000/castle/api/v1/entries/expense \
-H "X-Api-Key: YOUR_INVOICE_KEY" \
-d '{"description": "Test expense", "amount": "100.00 EUR", "account_name": "Expenses:Test"}'
# Check user balance
curl http://localhost:5000/castle/api/v1/balance \
-H "X-Api-Key: YOUR_INVOICE_KEY"
Debugging Fava Connection: Check logs for "Fava client initialized" message on startup. If missing, verify Fava is running and settings are correct.
Related Documentation
docs/README.md- User-facing overviewdocs/DOCUMENTATION.md- Comprehensive technical documentationdocs/BEANCOUNT_PATTERNS.md- Beancount-inspired design patternsdocs/PHASE1_COMPLETE.md,PHASE2_COMPLETE.md,PHASE3_COMPLETE.md- Development milestonesdocs/EXPENSE_APPROVAL.md- Manual payment request workflowdocs/DAILY_RECONCILIATION.md- Automated reconciliation system