Simplifies the representation of journal entry lines by replacing separate debit and credit fields with a single 'amount' field. Positive amounts represent debits, while negative amounts represent credits, aligning with Beancount's approach. This change improves code readability and simplifies calculations for balancing entries.
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 satoshisget_balance_fiat(currency)- Fiat balance for specific currencyget_all_fiat_balances()- All fiat balances
- Utility methods:
is_empty()- Check if no positionsis_zero()- Check if all positions sum to zeroto_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:
-
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)
-
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
-
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
-
check_balance_matches()(Lines 170-187):- Verify balance assertion for sats
-
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:
-
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
- Checks:
-
validate_balance()(Lines 127-177):- Validates balance assertions
- Checks both sats and fiat within tolerance
-
validate_receivable_entry()(Lines 180-199):- Validates receivable (user owes castle) entries
- Ensures positive amount
- Ensures revenue account type
-
validate_expense_entry()(Lines 202-227):- Validates expense entries
- Ensures positive amount
- Checks account type (expense or equity)
-
validate_payment_entry()(Lines 230-245):- Validates payment entries
- Ensures positive amount
-
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:
-
Imports (Lines 26-36):
- Import core accounting logic
- Import validation functions
-
get_account_balance()(Lines 347-377):- Refactored to use
BalanceCalculator.calculate_account_balance() - Removed duplicate logic
- Refactored to use
-
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
- Completely refactored to use:
-
get_all_user_balances()(Lines 438-459):- Simplified to call
get_user_balance()for each user - Eliminates code duplication
- Simplified to call
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 + orchestrationviews_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 = [
{"amount": 100000, "metadata": '{"fiat_currency": "EUR", "fiat_amount": "50.00"}'}, # Positive = debit
{"amount": -50000, "metadata": "{}"} # Negative = credit
]
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", "amount": 100000}, # Positive = debit
{"account_id": "acc2", "amount": -100000} # Negative = credit
]
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_balancedplugin - Implement
check_receivablesplugin - 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