diff --git a/BEANCOUNT_PATTERNS.md b/BEANCOUNT_PATTERNS.md new file mode 100644 index 0000000..13b0519 --- /dev/null +++ b/BEANCOUNT_PATTERNS.md @@ -0,0 +1,936 @@ +# Beancount Patterns Analysis for Castle Extension + +## Overview + +After analyzing the [Beancount repository](https://github.com/beancount/beancount), I've identified several excellent design patterns and architectural decisions that we should adopt or consider for the Castle Accounting extension. + +## Key Patterns to Adopt + +### 1. **Immutable Data Structures with NamedTuple** + +**Beancount Pattern:** +```python +class Transaction(NamedTuple): + """A transaction with immutable attributes""" + meta: Meta + date: datetime.date + flag: Flag + payee: Optional[str] + narration: str + tags: frozenset[str] + links: frozenset[str] + postings: list[Posting] + +class Posting(NamedTuple): + """Individual posting within a transaction""" + account: Account + units: Optional[Amount] + cost: Optional[Union[Cost, CostSpec]] + price: Optional[Amount] + flag: Optional[Flag] + meta: Optional[Meta] +``` + +**Benefits:** +- Immutability prevents accidental modifications +- NamedTuple provides free `__repr__`, `__eq__`, `__hash__` +- Type hints improve IDE support and catch errors +- More memory efficient than regular classes +- Thread-safe by design + +**Castle Application:** +```python +# In models.py +from typing import NamedTuple, Optional +from datetime import datetime +from decimal import Decimal + +class ImmutableJournalEntry(NamedTuple): + """Immutable representation of a journal entry""" + id: str + description: str + entry_date: datetime + created_by: str + created_at: datetime + reference: Optional[str] + lines: tuple[ImmutableEntryLine, ...] # Tuple, not list! + meta: dict[str, Any] # Could store filename, lineno for audit trail + +class ImmutableEntryLine(NamedTuple): + """Immutable representation of an entry line""" + id: str + journal_entry_id: str + account_id: str + debit: int + credit: int + description: Optional[str] + metadata: dict[str, Any] + flag: Optional[str] # Like Beancount: '!', '*', etc. + +# Conversion functions +def entry_to_immutable(entry: JournalEntry) -> ImmutableJournalEntry: + """Convert mutable Pydantic model to immutable NamedTuple""" + return ImmutableJournalEntry( + id=entry.id, + description=entry.description, + entry_date=entry.entry_date, + created_by=entry.created_by, + created_at=entry.created_at, + reference=entry.reference, + lines=tuple(line_to_immutable(line) for line in entry.lines), + meta={} + ) +``` + +**Recommendation:** Use immutable NamedTuples for internal processing, Pydantic models for API I/O. + +--- + +### 2. **Plugin Architecture** + +**Beancount Pattern:** +```python +# In plugins/check_commodity.py +__plugins__ = ('validate_commodity_directives',) + +def validate_commodity_directives(entries, options_map, config): + """ + Plugin that validates all commodities have declarations. + + Args: + entries: List of directive entries + options_map: Parser options + config: Plugin-specific configuration + + Returns: + (entries, errors) tuple + """ + errors = [] + # ... validation logic ... + return entries, errors +``` + +**Castle Application:** +```python +# Create plugins/ directory +# lnbits/extensions/castle/plugins/__init__.py + +from typing import Protocol, Tuple, List, Any + +class CastlePlugin(Protocol): + """Protocol for Castle plugins""" + + def __call__( + self, + entries: List[JournalEntry], + settings: dict[str, Any], + config: dict[str, Any] + ) -> Tuple[List[JournalEntry], List[dict]]: + """ + Process entries and return modified entries + errors. + + Args: + entries: Journal entries to process + settings: Castle settings + config: Plugin-specific configuration + + Returns: + (modified_entries, errors) tuple + """ + ... + +# Example plugins: + +# plugins/check_balanced.py +__plugins__ = ('check_all_balanced',) + +def check_all_balanced(entries, settings, config): + """Verify all journal entries have debits = credits""" + errors = [] + for entry in entries: + total_debits = sum(line.debit for line in entry.lines) + total_credits = sum(line.credit for line in entry.lines) + if total_debits != total_credits: + errors.append({ + 'entry_id': entry.id, + 'message': f'Unbalanced entry: debits={total_debits}, credits={total_credits}', + 'severity': 'error' + }) + return entries, errors + +# plugins/auto_tags.py +__plugins__ = ('auto_add_tags',) + +def auto_add_tags(entries, settings, config): + """Automatically add tags based on rules""" + tag_rules = config.get('rules', {}) + for entry in entries: + for pattern, tag in tag_rules.items(): + if pattern in entry.description.lower(): + if 'tags' not in entry.meta: + entry.meta['tags'] = set() + entry.meta['tags'].add(tag) + return entries, [] + +# plugins/check_receivables.py +def check_receivable_limits(entries, settings, config): + """Warn if receivables exceed configured limits""" + errors = [] + max_per_user = config.get('max_receivable_per_user', 1_000_000) + + # Calculate current receivables per user + receivables = {} + for entry in entries: + for line in entry.lines: + if 'Accounts Receivable' in line.account_name: + user_id = extract_user_from_account(line.account_name) + receivables[user_id] = receivables.get(user_id, 0) + line.debit - line.credit + + for user_id, amount in receivables.items(): + if amount > max_per_user: + errors.append({ + 'user_id': user_id, + 'message': f'Receivable {amount} sats exceeds limit {max_per_user}', + 'severity': 'warning' + }) + + return entries, errors +``` + +**Plugin Manager:** +```python +# plugins/manager.py +import importlib +from pathlib import Path + +class PluginManager: + def __init__(self, plugin_dir: Path): + self.plugins = [] + self.load_plugins(plugin_dir) + + def load_plugins(self, plugin_dir: Path): + """Discover and load all plugins""" + for plugin_file in plugin_dir.glob('*.py'): + if plugin_file.name.startswith('_'): + continue + + module_name = f"castle.plugins.{plugin_file.stem}" + module = importlib.import_module(module_name) + + if hasattr(module, '__plugins__'): + for plugin_name in module.__plugins__: + plugin_func = getattr(module, plugin_name) + self.plugins.append((plugin_name, plugin_func)) + + def run_plugins( + self, + entries: List[JournalEntry], + settings: dict, + plugin_configs: dict[str, dict] + ) -> Tuple[List[JournalEntry], List[dict]]: + """Run all plugins in sequence""" + all_errors = [] + + for plugin_name, plugin_func in self.plugins: + config = plugin_configs.get(plugin_name, {}) + entries, errors = plugin_func(entries, settings, config) + all_errors.extend(errors) + + return entries, all_errors +``` + +**Benefits:** +- Extensibility without modifying core code +- Easy to enable/disable validation rules +- Community can contribute plugins +- Each plugin is independently testable + +--- + +### 3. **Inventory System for Position Tracking** + +**Beancount Pattern:** +```python +class Inventory(dict[tuple[str, Optional[Cost]], Position]): + """ + Tracks positions keyed by (currency, cost). + Handles lot matching with FIFO, LIFO, HIFO strategies. + """ + + def add_position(self, position: Position): + """Add or merge a position""" + key = (position.units.currency, position.cost) + if key in self: + self[key] = self[key].add(position) + else: + self[key] = position + + def reduce(self, reducer_func): + """Convert inventory using a reduction function""" + return sum((reducer_func(pos) for pos in self), Inventory()) + + def get_currency_units(self, currency: str) -> Decimal: + """Get total units of a specific currency""" + return sum( + pos.units.number + for (curr, cost), pos in self.items() + if curr == currency + ) +``` + +**Castle Application:** +```python +# core/inventory.py +from decimal import Decimal +from typing import Optional, Dict, Tuple +from dataclasses import dataclass + +@dataclass(frozen=True) +class CastlePosition: + """A position in the Castle inventory""" + currency: str # "SATS", "EUR", "USD" + amount: Decimal + cost_currency: Optional[str] = None # Original currency if converted + cost_amount: Optional[Decimal] = None # Original amount + date: Optional[datetime] = None + metadata: Dict[str, Any] = None + +class CastleInventory: + """ + Track user balances across multiple currencies with conversion tracking. + Similar to Beancount's Inventory but optimized for Castle's use case. + """ + + def __init__(self): + self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {} + + def add_position(self, position: CastlePosition): + """Add or merge a position""" + key = (position.currency, position.cost_currency) + + if key in self.positions: + existing = self.positions[key] + self.positions[key] = CastlePosition( + currency=position.currency, + amount=existing.amount + position.amount, + cost_currency=position.cost_currency, + cost_amount=( + (existing.cost_amount or Decimal(0)) + + (position.cost_amount or Decimal(0)) + ), + date=position.date, + metadata={**(existing.metadata or {}), **(position.metadata or {})} + ) + else: + self.positions[key] = position + + def get_balance_sats(self) -> Decimal: + """Get total balance in satoshis""" + return sum( + pos.amount + for (curr, _), pos in self.positions.items() + if curr == "SATS" + ) + + def get_balance_fiat(self, currency: str) -> Decimal: + """Get balance in specific fiat currency from cost metadata""" + return sum( + pos.cost_amount or Decimal(0) + for (curr, cost_curr), pos in self.positions.items() + if cost_curr == currency + ) + + def to_dict(self) -> dict: + """Export to dictionary""" + return { + "sats": float(self.get_balance_sats()), + "fiat": { + curr: float(self.get_balance_fiat(curr)) + for curr in set( + cost_curr + for _, cost_curr in self.positions.keys() + if cost_curr + ) + } + } + +# Usage in balance calculation: +async def get_user_inventory(user_id: str) -> CastleInventory: + """Calculate user's inventory from journal entries""" + inventory = CastleInventory() + + user_accounts = await get_user_accounts(user_id) + for account in user_accounts: + entry_lines = await get_entry_lines_for_account(account.id) + + for line in entry_lines: + # Add as position + metadata = json.loads(line.metadata) if line.metadata else {} + + if line.debit > 0: + inventory.add_position(CastlePosition( + currency="SATS", + amount=Decimal(line.debit), + cost_currency=metadata.get("fiat_currency"), + cost_amount=Decimal(metadata.get("fiat_amount", 0)), + date=line.created_at, + metadata=metadata + )) + + if line.credit > 0: + inventory.add_position(CastlePosition( + currency="SATS", + amount=-Decimal(line.credit), + cost_currency=metadata.get("fiat_currency"), + cost_amount=-Decimal(metadata.get("fiat_amount", 0)), + date=line.created_at, + metadata=metadata + )) + + return inventory +``` + +**Benefits:** +- Cleaner abstraction for balance calculations +- Separates position tracking from database queries +- Easier to add lot tracking if needed later +- Consistent with accounting standards + +--- + +### 4. **Meta Attribute Pattern** + +**Beancount Pattern:** +Every directive has a `meta: dict[str, Any]` attribute that stores: +- `filename`: Source file +- `lineno`: Line number +- Custom metadata like tags, links, notes + +**Castle Application:** +```python +class JournalEntryMeta(BaseModel): + """Metadata for journal entries""" + filename: Optional[str] = None # For imported entries + lineno: Optional[int] = None + source: str = "api" # "api", "import", "plugin", "migration" + created_via: str = "web_ui" # "web_ui", "cli", "script" + tags: set[str] = set() # e.g., {"recurring", "automated"} + links: set[str] = set() # Link to other entries + reviewed: bool = False + reviewed_by: Optional[str] = None + reviewed_at: Optional[datetime] = None + notes: Optional[str] = None + +# Add to journal_entries table: +""" +ALTER TABLE journal_entries ADD COLUMN meta TEXT DEFAULT '{}'; +""" + +# Usage: +entry = await create_journal_entry( + data=entry_data, + created_by=user_id, + meta={ + "source": "api", + "created_via": "web_ui", + "tags": ["manual", "receivable"], + "notes": "User requested invoice #1234" + } +) +``` + +**Benefits:** +- Audit trail for compliance +- Better debugging ("where did this entry come from?") +- Support for tags and categorization +- Can track review/approval workflow + +--- + +### 5. **Balance Assertion Pattern** + +**Beancount Pattern:** +```beancount +2025-10-22 balance Assets:Lightning:Balance 268548 SATS +``` + +This asserts that the account balance should be exactly 268,548 sats on that date. If it's not, Beancount throws an error. + +**Castle Application:** +```python +# models.py +class BalanceAssertion(BaseModel): + """Assert expected balance at a specific date""" + id: str + date: datetime + account_id: str + expected_balance: int # in satoshis + tolerance: int = 0 # Allow +/- this much difference + checked_balance: Optional[int] = None + difference: Optional[int] = None + status: str = "pending" # pending, passed, failed + created_by: str + created_at: datetime + +# API endpoint +@castle_api_router.post("/api/v1/assertions/balance") +async def create_balance_assertion( + data: CreateBalanceAssertion, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> BalanceAssertion: + """Create a balance assertion for reconciliation""" + assertion = BalanceAssertion( + id=urlsafe_short_hash(), + date=data.date, + account_id=data.account_id, + expected_balance=data.expected_balance, + tolerance=data.tolerance, + created_by=wallet.wallet.user, + created_at=datetime.now() + ) + + # Check balance immediately + actual_balance = await get_account_balance_at_date( + data.account_id, + data.date + ) + + difference = abs(actual_balance - data.expected_balance) + + assertion.checked_balance = actual_balance + assertion.difference = actual_balance - data.expected_balance + assertion.status = "passed" if difference <= data.tolerance else "failed" + + await db.insert("balance_assertions", assertion) + + if assertion.status == "failed": + raise HTTPException( + status_code=HTTPStatus.CONFLICT, + detail=f"Balance assertion failed: expected {data.expected_balance}, " + f"got {actual_balance}, difference {assertion.difference}" + ) + + return assertion + +# Periodic check job +async def check_all_balance_assertions(): + """Recheck all balance assertions (run daily)""" + assertions = await db.fetchall( + "SELECT * FROM balance_assertions WHERE status = 'pending'" + ) + + for assertion in assertions: + actual_balance = await get_account_balance_at_date( + assertion.account_id, + assertion.date + ) + + difference = abs(actual_balance - assertion.expected_balance) + status = "passed" if difference <= assertion.tolerance else "failed" + + await db.execute( + """ + UPDATE balance_assertions + SET checked_balance = :actual, difference = :diff, status = :status + WHERE id = :id + """, + { + "actual": actual_balance, + "diff": actual_balance - assertion.expected_balance, + "status": status, + "id": assertion.id + } + ) +``` + +**Benefits:** +- Catch data entry errors early +- Reconciliation checkpoints +- Build confidence in accounting accuracy +- Required for external audits + +--- + +### 6. **Hierarchical Account Names** + +**Beancount Pattern:** +``` +Assets:US:BofA:Checking +Assets:US:ETrade:Cash +Assets:US:ETrade:ITOT +Liabilities:US:CreditCard:Amex +``` + +Accounts are organized hierarchically with `:` separator. + +**Castle Application:** +```python +# Currently: "Accounts Receivable - af983632" +# Better: "Assets:Receivable:User-af983632" + +# Update account naming: +ACCOUNT_HIERARCHY = { + "asset": "Assets", + "liability": "Liabilities", + "equity": "Equity", + "revenue": "Income", # Beancount uses "Income" not "Revenue" + "expense": "Expenses" +} + +def format_account_name( + account_type: AccountType, + base_name: str, + user_id: Optional[str] = None +) -> str: + """Format account name in hierarchical style""" + root = ACCOUNT_HIERARCHY[account_type.value] + + # Convert "Accounts Receivable" -> "Receivable" + # Convert "Food & Supplies" -> "Food:Supplies" + base_clean = base_name.replace("Accounts ", "").replace(" & ", ":") + + if user_id: + return f"{root}:{base_clean}:User-{user_id[:8]}" + else: + return f"{root}:{base_clean}" + +# Examples: +# "Assets:Receivable:User-af983632" +# "Liabilities:Payable:User-af983632" +# "Equity:MemberEquity:User-af983632" +# "Income:Accommodation" +# "Expenses:Food:Supplies" +# "Assets:Lightning:Balance" +``` + +**Benefits:** +- Standard accounting hierarchy +- Better organization for reporting +- Easier to generate balance sheet (all Assets:*, all Liabilities:*, etc.) +- Compatible with Beancount import + +--- + +### 7. **Flags for Transaction Status** + +**Beancount Pattern:** +```beancount +2025-10-22 * "Confirmed transaction" + Assets:Checking -100 USD + Expenses:Food 100 USD + +2025-10-23 ! "Pending/uncleared transaction" + Assets:Checking -50 USD + Expenses:Gas 50 USD +``` + +Flags: `*` = cleared, `!` = pending, `#` = flagged for review + +**Castle Application:** +```python +# Add flag field to journal_entries +class JournalEntryFlag(str, Enum): + CLEARED = "*" # Fully reconciled + PENDING = "!" # Not yet confirmed + FLAGGED = "#" # Needs review + VOID = "x" # Voided entry + +class JournalEntry(BaseModel): + # ... existing fields ... + flag: JournalEntryFlag = JournalEntryFlag.CLEARED + +# Usage: +# Lightning payment: flag="*" (immediately cleared) +# Manual payment request: flag="!" (pending approval) +# Suspicious entry: flag="#" (flagged for review) +# Reversed entry: flag="x" (voided) + +# UI: Show icon based on flag +# * = green checkmark +# ! = yellow warning +# # = red flag +# x = gray strikethrough +``` + +**Benefits:** +- Visual indication of entry status +- Filter by flag in queries +- Standard accounting practice +- Supports reconciliation workflow + +--- + +### 8. **Decimal for Money, Not Float** + +**Beancount Pattern:** +```python +from decimal import Decimal + +# All amounts use Decimal, never float +amount = Decimal("19.99") +``` + +**Castle Current Issue:** +We're using `int` for satoshis (good!) but `float` for fiat amounts (bad!). + +**Fix:** +```python +from decimal import Decimal + +class ExpenseEntry(BaseModel): + amount: Decimal # Not float! + # ... + +class ReceivableEntry(BaseModel): + amount: Decimal # Not float! + # ... + +# Metadata storage +metadata = { + "fiat_amount": str(Decimal("250.00")), # Store as string to preserve precision + "fiat_rate": str(Decimal("1074.192")), +} + +# When reading: +fiat_amount = Decimal(metadata["fiat_amount"]) +``` + +**Benefits:** +- Prevents floating point rounding errors +- Required for financial applications +- Exact decimal arithmetic +- `Decimal("0.1") + Decimal("0.2") == Decimal("0.3")` (float doesn't!) + +--- + +### 9. **Query Language (Future Enhancement)** + +**Beancount Pattern:** +```sql +-- Beancount Query Language (BQL) +SELECT account, sum(position) AS balance +WHERE account ~ 'Assets:' +GROUP BY account +ORDER BY balance DESC; + +SELECT date, narration, position +WHERE account = 'Assets:Checking' + AND date >= 2025-01-01; +``` + +**Castle Application (Future):** +```python +# Add query endpoint +@castle_api_router.post("/api/v1/query") +async def execute_query( + query: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +) -> dict: + """ + Execute a query against accounting data. + Supports SQL-like syntax for reporting. + """ + # Parse query (could use SQLAlchemy for this) + # Apply user permissions (filter to user's accounts) + # Execute and return results + pass + +# Example queries users could run: +# - "Show all expenses in October" +# - "What's my average monthly spending on food?" +# - "Show all receivables older than 30 days" +``` + +**Benefits:** +- Power users can build custom reports +- Reduces need for custom endpoints +- Flexible analytics + +--- + +## Structural Improvements + +### 1. **Separate Core from API** + +**Beancount Structure:** +``` +beancount/ + core/ # Pure data structures and logic + data.py # Immutable data types + inventory.py # Position tracking + account.py # Account operations + parser/ # Text file parsing + plugins/ # Validation and transformation + scripts/ # CLI tools + tools/ # Reporting and analysis +``` + +**Castle Should Adopt:** +``` +castle/ + core/ # NEW: Pure accounting logic + __init__.py + inventory.py # CastleInventory for position tracking + balance.py # Balance calculation logic + validation.py # Entry validation (debits=credits, etc) + account.py # Account hierarchy and naming + models.py # Pydantic models (API I/O) + crud.py # Database operations + views_api.py # API endpoints + plugins/ # NEW: Validation plugins + __init__.py + check_balanced.py + check_receivables.py + services.py # Business logic + migrations.py + templates/ + static/ +``` + +**Benefits:** +- Core logic is independent of database +- Easier to test (no DB mocking needed) +- Could swap DB backend +- Core logic can be reused in CLI tools + +--- + +### 2. **Error Handling with Typed Errors** + +**Beancount Pattern:** +```python +ConfigError = collections.namedtuple('ConfigError', 'source message entry') +ValidationError = collections.namedtuple('ValidationError', 'source message entry') + +def validate_entries(entries): + errors = [] + for entry in entries: + if not is_valid(entry): + errors.append(ValidationError( + source={'filename': entry.meta['filename'], 'lineno': entry.meta['lineno']}, + message="Entry is invalid", + entry=entry + )) + return errors +``` + +**Castle Application:** +```python +from typing import NamedTuple, Optional + +class CastleError(NamedTuple): + """Base error type""" + source: dict # {'endpoint': '...', 'user_id': '...'} + message: str + entry: Optional[dict] # Related entry data + severity: str # 'error', 'warning', 'info' + code: str # 'UNBALANCED_ENTRY', 'MISSING_ACCOUNT', etc. + +class UnbalancedEntryError(NamedTuple): + source: dict + message: str + entry: dict + severity: str = 'error' + code: str = 'UNBALANCED_ENTRY' + total_debits: int + total_credits: int + difference: int + +# Return errors from validation +async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]: + errors = [] + + total_debits = sum(line.debit for line in entry.lines) + total_credits = sum(line.credit for line in entry.lines) + + if total_debits != total_credits: + errors.append(UnbalancedEntryError( + source={'created_by': entry.created_by}, + message=f"Entry does not balance: debits={total_debits}, credits={total_credits}", + entry=entry.dict(), + total_debits=total_debits, + total_credits=total_credits, + difference=total_debits - total_credits + )) + + return errors +``` + +**Benefits:** +- Structured error information +- Easier to log and track +- Can return multiple errors at once +- Better error messages for users + +--- + +## Implementation Roadmap + +### Phase 1: Foundation (High Priority) +1. ✅ Switch from `float` to `Decimal` for fiat amounts +2. ✅ Add `meta` field to journal entries for audit trail +3. ✅ Add `flag` field for transaction status +4. ✅ Implement hierarchical account naming + +### Phase 2: Core Logic (Medium Priority) +5. Create `core/` module with pure accounting logic +6. Implement `CastleInventory` for position tracking +7. Move balance calculation to `core/balance.py` +8. Add comprehensive validation in `core/validation.py` + +### Phase 3: Validation (Medium Priority) +9. Create plugin system architecture +10. Implement `check_balanced` plugin +11. Implement `check_receivables` plugin +12. Add plugin configuration UI + +### Phase 4: Reconciliation (High Priority) +13. Implement balance assertions +14. Add reconciliation API endpoints +15. Build reconciliation UI +16. Add automated daily balance checks + +### Phase 5: Advanced Features (Low Priority) +17. Add tags and links to entries +18. Implement query language +19. Add lot tracking to inventory +20. Support multi-currency in single entry + +--- + +## Key Takeaways + +**What Beancount Does Right:** +1. ✅ Immutable data structures +2. ✅ Plugin architecture for extensibility +3. ✅ Rich metadata on every directive +4. ✅ Balance assertions for reconciliation +5. ✅ Hierarchical account naming +6. ✅ Use of Decimal for precision +7. ✅ Separation of core logic from I/O +8. ✅ Comprehensive validation + +**What Castle Should Adopt First:** +1. **Decimal for fiat amounts** (prevent rounding errors) +2. **Meta field** (audit trail, source tracking) +3. **Flag field** (transaction status) +4. **Balance assertions** (reconciliation) +5. **Plugin system** (extensible validation) + +**What Can Wait:** +- Query language (nice to have) +- Lot tracking (not needed for current use case) +- Full immutability (Pydantic models are fine for now) + +--- + +## Conclusion + +Beancount's architecture is battle-tested for serious accounting work. By adopting these patterns, Castle can: +- Prevent financial calculation errors (Decimal) +- Support complex workflows (plugins) +- Build user trust (balance assertions, audit trail) +- Scale to larger deployments (clean architecture) +- Export to professional tools (hierarchical accounts) + +The most important immediate change is switching from `float` to `Decimal` for fiat amounts. This alone will prevent serious bugs. The plugin system and balance assertions should follow shortly after as they're critical for financial correctness. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..936802b --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,1213 @@ +# 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 + + Export to Beancount + Download accounting data in Beancount format + +``` + +```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. diff --git a/static/image/castle.png b/static/image/castle.png index 5098b34..489be52 100644 Binary files a/static/image/castle.png and b/static/image/castle.png differ diff --git a/static/image/castle.svg b/static/image/castle.svg deleted file mode 100644 index 65a92f3..0000000 --- a/static/image/castle.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - -