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

10 KiB

Phase 3: Core Logic Refactoring - COMPLETE

Summary

Phase 3 of the Beancount-inspired refactor focused on separating business logic from database operations and creating a clean, testable core module. This phase improves code quality, maintainability, and follows best practices from Beancount's architecture.

Completed Features

1. Core Module Structure

Purpose: Separate pure accounting logic from database and API concerns

Implementation (core/__init__.py):

  • Created core/ module package
  • Exports main classes and functions
  • Clean separation of concerns

Benefits:

  • Testable without database
  • Reusable across different storage backends
  • Easier to audit and verify
  • Clear architecture

2. CastleInventory for Position Tracking

Purpose: Track balances across multiple currencies with cost basis information (following Beancount's Inventory pattern)

Implementation (core/inventory.py):

CastlePosition (Lines 11-84):

  • Immutable dataclass representing a single position
  • Tracks currency, amount, cost basis, and metadata
  • Supports addition and negation operations
  • Automatic Decimal conversion in __post_init__
@dataclass(frozen=True)
class CastlePosition:
    currency: str  # "SATS", "EUR", "USD"
    amount: Decimal
    cost_currency: Optional[str] = None
    cost_amount: Optional[Decimal] = None
    date: Optional[datetime] = None
    metadata: Dict[str, Any] = field(default_factory=dict)

CastleInventory (Lines 87-201):

  • Container for multiple positions
  • Positions keyed by (currency, cost_currency) tuple
  • Methods for querying balances:
    • get_balance_sats() - Total satoshis
    • get_balance_fiat(currency) - Fiat balance for specific currency
    • get_all_fiat_balances() - All fiat balances
  • Utility methods:
    • is_empty() - Check if no positions
    • is_zero() - Check if all positions sum to zero
    • to_dict() - Export to dictionary

3. BalanceCalculator

Purpose: Pure logic for calculating balances from journal entries

Implementation (core/balance.py):

AccountType Enum (Lines 13-19):

class AccountType(str, Enum):
    ASSET = "asset"
    LIABILITY = "liability"
    EQUITY = "equity"
    REVENUE = "revenue"
    EXPENSE = "expense"

BalanceCalculator Class (Lines 22-217):

Static Methods:

  1. calculate_account_balance() (Lines 29-54):

    • Calculate balance based on account type
    • Normal balances:
      • Assets/Expenses: Debit balance (debit - credit)
      • Liabilities/Equity/Revenue: Credit balance (credit - debit)
  2. build_inventory_from_entry_lines() (Lines 56-117):

    • Build CastleInventory from journal entry lines
    • Handles both sats and fiat currency tracking
    • Accounts for account type when determining sign
  3. calculate_user_balance() (Lines 119-168):

    • Calculate user's total balance across all accounts
    • Returns both sats balance and fiat balances by currency
    • Properly handles asset (receivable) vs liability (payable) accounts
  4. check_balance_matches() (Lines 170-187):

    • Verify balance assertion for sats
  5. check_fiat_balance_matches() (Lines 189-202):

    • Verify balance assertion for fiat currency

4. Comprehensive Validation

Purpose: Validation rules for accounting operations

Implementation (core/validation.py):

ValidationError Exception (Lines 10-18):

  • Custom exception for validation failures
  • Includes detailed error information

Validation Functions:

  1. validate_journal_entry() (Lines 21-124):

    • Checks:
      • At least 2 lines (double-entry requirement)
      • Entry is balanced (debits = credits)
      • Valid amounts (non-negative)
      • No line has both debit and credit
      • All lines have account_id
  2. validate_balance() (Lines 127-177):

    • Validates balance assertions
    • Checks both sats and fiat within tolerance
  3. validate_receivable_entry() (Lines 180-199):

    • Validates receivable (user owes castle) entries
    • Ensures positive amount
    • Ensures revenue account type
  4. validate_expense_entry() (Lines 202-227):

    • Validates expense entries
    • Ensures positive amount
    • Checks account type (expense or equity)
  5. validate_payment_entry() (Lines 230-245):

    • Validates payment entries
    • Ensures positive amount
  6. validate_metadata() (Lines 248-284):

    • Validates entry line metadata
    • Checks for required keys
    • Validates fiat currency/amount consistency
    • Validates Decimal conversion

5. Refactored CRUD Operations

Purpose: Use core logic in database operations

Modified Files: crud.py

Changes:

  1. Imports (Lines 26-36):

    • Import core accounting logic
    • Import validation functions
  2. get_account_balance() (Lines 347-377):

    • Refactored to use BalanceCalculator.calculate_account_balance()
    • Removed duplicate logic
  3. get_user_balance() (Lines 380-435):

    • Completely refactored to use:
      • BalanceCalculator.build_inventory_from_entry_lines()
      • BalanceCalculator.calculate_user_balance()
    • Cleaner separation of database queries vs business logic
  4. get_all_user_balances() (Lines 438-459):

    • Simplified to call get_user_balance() for each user
    • Eliminates code duplication

Architecture

Before Phase 3

views_api.py → crud.py (mixed DB + logic)
                  ↓
               database

All accounting logic was embedded in crud.py alongside database operations.

After Phase 3

views_api.py → crud.py → core/
                  ↓         ↓
              database   Pure Logic
                         (testable)

Separation of Concerns:

  • core/ - Pure accounting logic (no DB dependencies)
  • crud.py - Database operations + orchestration
  • views_api.py - HTTP API layer

Benefits

Code Quality

  • Testability: Core logic can be tested without database
  • Maintainability: Clear separation makes code easier to understand
  • Reusability: Core logic can be used in different contexts
  • Consistency: Centralized accounting rules

Developer Experience

  • Type Safety: Immutable dataclasses with proper types
  • Documentation: Well-documented core functions
  • Debugging: Easier to trace accounting logic
  • Refactoring: Safer to make changes

Reliability

  • Validation: Comprehensive validation rules
  • Correctness: Pure functions easier to verify
  • Auditability: Clear accounting rules

File Structure

lnbits/extensions/castle/
├── core/
│   ├── __init__.py          # Module exports
│   ├── inventory.py         # CastleInventory, CastlePosition
│   ├── balance.py           # BalanceCalculator
│   └── validation.py        # Validation functions
├── crud.py                  # DB operations (refactored to use core/)
├── models.py                # Pydantic models
├── views_api.py             # API endpoints
└── PHASE3_COMPLETE.md       # This file

Usage Examples

Using CastleInventory

from decimal import Decimal
from castle.core.inventory import CastleInventory, CastlePosition

# Create inventory
inv = CastleInventory()

# Add positions
inv.add_position(CastlePosition(
    currency="SATS",
    amount=Decimal("100000")
))

inv.add_position(CastlePosition(
    currency="SATS",
    amount=Decimal("50000"),
    cost_currency="EUR",
    cost_amount=Decimal("25.00")
))

# Query balances
total_sats = inv.get_balance_sats()  # Decimal("150000")
eur_balance = inv.get_balance_fiat("EUR")  # Decimal("25.00")

# Export
data = inv.to_dict()
# {"sats": 150000, "fiat": {"EUR": 25.00}}

Using BalanceCalculator

from castle.core.balance import BalanceCalculator, AccountType

# Calculate account balance
balance = BalanceCalculator.calculate_account_balance(
    total_debit=100000,
    total_credit=50000,
    account_type=AccountType.ASSET
)
# Returns: 50000 (debit balance for asset)

# Build inventory from entry lines
entry_lines = [
    {"debit": 100000, "credit": 0, "metadata": '{"fiat_currency": "EUR", "fiat_amount": "50.00"}'},
    {"debit": 0, "credit": 50000, "metadata": "{}"}
]

inventory = BalanceCalculator.build_inventory_from_entry_lines(
    entry_lines,
    AccountType.ASSET
)

# Check balance matches
is_valid = BalanceCalculator.check_balance_matches(
    actual_balance_sats=100000,
    expected_balance_sats=99900,
    tolerance_sats=100
)
# Returns: True (within tolerance)

Using Validation

from castle.core.validation import validate_journal_entry, ValidationError

entry = {
    "id": "abc123",
    "description": "Test entry",
    "entry_date": datetime.now()
}

entry_lines = [
    {"account_id": "acc1", "debit": 100000, "credit": 0},
    {"account_id": "acc2", "debit": 0, "credit": 100000}
]

try:
    validate_journal_entry(entry, entry_lines)
    print("Valid!")
except ValidationError as e:
    print(f"Invalid: {e.message}")
    print(f"Details: {e.details}")

Testing Checklist

  • CastleInventory created and tested
  • CastlePosition addition works
  • Inventory balance calculations work
  • BalanceCalculator account balance calculation works
  • BalanceCalculator inventory building works
  • BalanceCalculator user balance calculation works
  • Validation functions work
  • crud.py refactored to use core logic
  • Existing balance calculations still work
  • Unit tests for core module (future work)

Next Steps

Phase 4: Validation Plugins (Medium Priority)

  • Create plugin system architecture
  • Implement check_balanced plugin
  • Implement check_receivables plugin
  • Add plugin configuration UI

Future Enhancements:

  • Add unit tests for core/ module
  • Add integration tests
  • Add lot tracking to inventory
  • Support multi-currency in single entry
  • Add more validation plugins

Conclusion

Phase 3 successfully refactors Castle's accounting logic into a clean, testable core module. By following Beancount's architecture patterns, we've created:

  • Pure accounting logic separated from database concerns
  • CastleInventory for position tracking across currencies
  • BalanceCalculator for consistent balance calculations
  • Comprehensive validation for data integrity

The refactoring improves code quality, maintainability, and sets the foundation for Phase 4's plugin system.

Phase 3 Status: COMPLETE


Generated: 2025-10-23 Next: Phase 4 - Validation Plugins