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.
365 lines
10 KiB
Markdown
365 lines
10 KiB
Markdown
# 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__`
|
|
|
|
```python
|
|
@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):
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
- [x] CastleInventory created and tested
|
|
- [x] CastlePosition addition works
|
|
- [x] Inventory balance calculations work
|
|
- [x] BalanceCalculator account balance calculation works
|
|
- [x] BalanceCalculator inventory building works
|
|
- [x] BalanceCalculator user balance calculation works
|
|
- [x] Validation functions work
|
|
- [x] crud.py refactored to use core logic
|
|
- [x] 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*
|