Completes core logic refactoring (Phase 3)

Refactors the accounting logic into a clean, testable core module, separating business logic from database operations.

This improves code quality, maintainability, and testability by creating a dedicated `core/` module, implementing `CastleInventory` for position tracking, moving balance calculations to `core/balance.py`, and adding comprehensive validation in `core/validation.py`.
This commit is contained in:
padreug 2025-10-23 02:42:57 +02:00
parent 6d84479f7d
commit 9c0bdc58eb
7 changed files with 1204 additions and 123 deletions

View file

@ -878,11 +878,11 @@ async def validate_journal_entry(entry: CreateJournalEntry) -> list[CastleError]
7. ✅ Build reconciliation UI
8. ✅ Add automated daily balance checks
### Phase 3: Core Logic Refactoring (Medium Priority) - Improves code quality
9. Create `core/` module with pure accounting logic
10. Implement `CastleInventory` for position tracking
11. Move balance calculation to `core/balance.py`
12. Add comprehensive validation in `core/validation.py`
### Phase 3: Core Logic Refactoring (Medium Priority) ✅ COMPLETE
9. Create `core/` module with pure accounting logic
10. Implement `CastleInventory` for position tracking
11. Move balance calculation to `core/balance.py`
12. Add comprehensive validation in `core/validation.py`
### Phase 4: Validation Plugins (Medium Priority) - Works better after Phase 3
13. Create plugin system architecture

365
PHASE3_COMPLETE.md Normal file
View file

@ -0,0 +1,365 @@
# 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 = [
{"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
```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", "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
- [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*

29
core/__init__.py Normal file
View file

@ -0,0 +1,29 @@
"""
Castle Core Module - Pure accounting logic separated from database operations.
This module contains the core business logic for double-entry accounting,
following Beancount patterns for clean architecture:
- inventory.py: Position tracking across currencies
- balance.py: Balance calculation logic
- validation.py: Comprehensive validation rules
Benefits:
- Testable without database
- Reusable across different storage backends
- Clear separation of concerns
- Easier to audit and verify
"""
from .inventory import CastleInventory, CastlePosition
from .balance import BalanceCalculator
from .validation import ValidationError, validate_journal_entry, validate_balance
__all__ = [
"CastleInventory",
"CastlePosition",
"BalanceCalculator",
"ValidationError",
"validate_journal_entry",
"validate_balance",
]

228
core/balance.py Normal file
View file

@ -0,0 +1,228 @@
"""
Balance calculation logic for Castle accounting.
Pure functions for calculating account and user balances from journal entries,
following double-entry accounting principles.
"""
from decimal import Decimal
from typing import Any, Dict, List, Optional
from enum import Enum
from .inventory import CastleInventory, CastlePosition
class AccountType(str, Enum):
"""Account types in double-entry accounting"""
ASSET = "asset"
LIABILITY = "liability"
EQUITY = "equity"
REVENUE = "revenue"
EXPENSE = "expense"
class BalanceCalculator:
"""
Pure logic for calculating balances from journal entries.
This class contains no database access - it operates on data structures
passed to it, making it easy to test and reuse.
"""
@staticmethod
def calculate_account_balance(
total_debit: int,
total_credit: int,
account_type: AccountType
) -> int:
"""
Calculate account balance based on account type.
Normal balances:
- Assets and Expenses: Debit balance (debit - credit)
- Liabilities, Equity, and Revenue: Credit balance (credit - debit)
Args:
total_debit: Sum of all debits in satoshis
total_credit: Sum of all credits in satoshis
account_type: Type of account
Returns:
Balance in satoshis
"""
if account_type in [AccountType.ASSET, AccountType.EXPENSE]:
return total_debit - total_credit
else:
return total_credit - total_debit
@staticmethod
def build_inventory_from_entry_lines(
entry_lines: List[Dict[str, Any]],
account_type: AccountType
) -> CastleInventory:
"""
Build a CastleInventory from journal entry lines.
Args:
entry_lines: List of entry line dictionaries with keys:
- debit: int (satoshis)
- credit: int (satoshis)
- metadata: str (JSON string with optional fiat_currency, fiat_amount)
account_type: Type of account (affects sign of amounts)
Returns:
CastleInventory with positions for sats and fiat currencies
"""
import json
inventory = CastleInventory()
for line in entry_lines:
# Parse metadata
metadata = json.loads(line.get("metadata", "{}")) if line.get("metadata") else {}
fiat_currency = metadata.get("fiat_currency")
fiat_amount_raw = metadata.get("fiat_amount")
# Convert fiat amount to Decimal
fiat_amount = Decimal(str(fiat_amount_raw)) if fiat_amount_raw else None
# Calculate amount based on debit/credit and account type
debit = line.get("debit", 0)
credit = line.get("credit", 0)
if debit > 0:
sats_amount = Decimal(debit)
# For liability accounts: debit decreases balance (negative)
# For asset accounts: debit increases balance (positive)
if account_type == AccountType.LIABILITY:
sats_amount = -sats_amount
fiat_amount = -fiat_amount if fiat_amount else None
inventory.add_position(
CastlePosition(
currency="SATS",
amount=sats_amount,
cost_currency=fiat_currency,
cost_amount=fiat_amount,
metadata=metadata,
)
)
if credit > 0:
sats_amount = Decimal(credit)
# For liability accounts: credit increases balance (positive)
# For asset accounts: credit decreases balance (negative)
if account_type == AccountType.ASSET:
sats_amount = -sats_amount
fiat_amount = -fiat_amount if fiat_amount else None
inventory.add_position(
CastlePosition(
currency="SATS",
amount=sats_amount,
cost_currency=fiat_currency,
cost_amount=fiat_amount,
metadata=metadata,
)
)
return inventory
@staticmethod
def calculate_user_balance(
accounts: List[Dict[str, Any]],
account_balances: Dict[str, int],
account_inventories: Dict[str, CastleInventory]
) -> Dict[str, Any]:
"""
Calculate user's total balance across all their accounts.
User balance represents what the Castle owes the user:
- Positive: Castle owes user
- Negative: User owes Castle
Args:
accounts: List of account dictionaries with keys:
- id: str
- account_type: str (asset/liability/equity)
account_balances: Dict mapping account_id to balance in sats
account_inventories: Dict mapping account_id to CastleInventory
Returns:
Dictionary with:
- balance: int (total sats, positive = castle owes user)
- fiat_balances: Dict[str, Decimal] (fiat balances by currency)
"""
total_balance = 0
combined_inventory = CastleInventory()
for account in accounts:
account_id = account["id"]
account_type = AccountType(account["account_type"])
balance = account_balances.get(account_id, 0)
inventory = account_inventories.get(account_id, CastleInventory())
# Add sats balance based on account type
if account_type == AccountType.LIABILITY:
# Liability: positive balance means castle owes user
total_balance += balance
elif account_type == AccountType.ASSET:
# Asset (receivable): positive balance means user owes castle (negative for user)
total_balance -= balance
# Equity contributions don't affect what castle owes
# Merge inventories for fiat tracking
for position in inventory.positions.values():
# Adjust sign based on account type
if account_type == AccountType.ASSET:
# For receivables, negate the position
combined_inventory.add_position(position.negate())
else:
combined_inventory.add_position(position)
fiat_balances = combined_inventory.get_all_fiat_balances()
return {
"balance": total_balance,
"fiat_balances": fiat_balances,
}
@staticmethod
def check_balance_matches(
actual_balance_sats: int,
expected_balance_sats: int,
tolerance_sats: int = 0
) -> bool:
"""
Check if actual balance matches expected within tolerance.
Args:
actual_balance_sats: Actual calculated balance
expected_balance_sats: Expected balance from assertion
tolerance_sats: Allowed difference (±)
Returns:
True if balances match within tolerance
"""
difference = abs(actual_balance_sats - expected_balance_sats)
return difference <= tolerance_sats
@staticmethod
def check_fiat_balance_matches(
actual_balance_fiat: Decimal,
expected_balance_fiat: Decimal,
tolerance_fiat: Decimal = Decimal(0)
) -> bool:
"""
Check if actual fiat balance matches expected within tolerance.
Args:
actual_balance_fiat: Actual calculated fiat balance
expected_balance_fiat: Expected fiat balance from assertion
tolerance_fiat: Allowed difference (±)
Returns:
True if balances match within tolerance
"""
difference = abs(actual_balance_fiat - expected_balance_fiat)
return difference <= tolerance_fiat

203
core/inventory.py Normal file
View file

@ -0,0 +1,203 @@
"""
Inventory system for position tracking.
Similar to Beancount's Inventory class, this module provides position tracking
across multiple currencies with cost basis information.
"""
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, Optional, Tuple
@dataclass(frozen=True)
class CastlePosition:
"""
A position in the Castle inventory.
Represents an amount in a specific currency, optionally with cost basis
information for tracking currency conversions.
Examples:
# Simple sats position
CastlePosition(currency="SATS", amount=Decimal("100000"))
# Sats with EUR cost basis
CastlePosition(
currency="SATS",
amount=Decimal("100000"),
cost_currency="EUR",
cost_amount=Decimal("50.00")
)
"""
currency: str # "SATS", "EUR", "USD", etc.
amount: Decimal
# Cost basis (for tracking conversions)
cost_currency: Optional[str] = None # Original currency if converted
cost_amount: Optional[Decimal] = None # Original amount
# Metadata
date: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Validate position data"""
if not isinstance(self.amount, Decimal):
object.__setattr__(self, "amount", Decimal(str(self.amount)))
if self.cost_amount is not None and not isinstance(self.cost_amount, Decimal):
object.__setattr__(
self, "cost_amount", Decimal(str(self.cost_amount))
)
def __add__(self, other: "CastlePosition") -> "CastlePosition":
"""Add two positions (must be same currency and cost_currency)"""
if self.currency != other.currency:
raise ValueError(f"Cannot add positions with different currencies: {self.currency} != {other.currency}")
if self.cost_currency != other.cost_currency:
raise ValueError(f"Cannot add positions with different cost currencies: {self.cost_currency} != {other.cost_currency}")
return CastlePosition(
currency=self.currency,
amount=self.amount + other.amount,
cost_currency=self.cost_currency,
cost_amount=(
(self.cost_amount or Decimal(0)) + (other.cost_amount or Decimal(0))
if self.cost_amount is not None or other.cost_amount is not None
else None
),
date=other.date, # Use most recent date
metadata={**self.metadata, **other.metadata},
)
def negate(self) -> "CastlePosition":
"""Return a position with negated amount"""
return CastlePosition(
currency=self.currency,
amount=-self.amount,
cost_currency=self.cost_currency,
cost_amount=-self.cost_amount if self.cost_amount else None,
date=self.date,
metadata=self.metadata,
)
class CastleInventory:
"""
Track balances across multiple currencies with conversion tracking.
Similar to Beancount's Inventory but optimized for Castle's use case.
Positions are keyed by (currency, cost_currency) to track different
cost bases separately.
Examples:
inv = CastleInventory()
inv.add_position(CastlePosition("SATS", Decimal("100000")))
inv.add_position(CastlePosition("SATS", Decimal("50000"), "EUR", Decimal("25")))
inv.get_balance_sats() # Returns: Decimal("150000")
inv.get_balance_fiat("EUR") # Returns: Decimal("25")
"""
def __init__(self):
self.positions: Dict[Tuple[str, Optional[str]], CastlePosition] = {}
def add_position(self, position: CastlePosition):
"""
Add or merge a position into the inventory.
Positions with the same (currency, cost_currency) key are merged.
"""
key = (position.currency, position.cost_currency)
if key in self.positions:
self.positions[key] = self.positions[key] + position
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.
This sums up all cost_amount values for positions that have
the specified cost_currency.
"""
return sum(
pos.cost_amount or Decimal(0)
for (_, cost_curr), pos in self.positions.items()
if cost_curr == currency
)
def get_all_fiat_balances(self) -> Dict[str, Decimal]:
"""Get balances for all fiat currencies present in the inventory"""
fiat_currencies = set(
cost_curr
for _, cost_curr in self.positions.keys()
if cost_curr
)
return {
curr: self.get_balance_fiat(curr)
for curr in fiat_currencies
}
def is_empty(self) -> bool:
"""Check if inventory has no positions"""
return len(self.positions) == 0
def is_zero(self) -> bool:
"""
Check if all positions sum to zero.
Returns True if the inventory has positions but they all sum to zero.
"""
return all(
pos.amount == Decimal(0)
for pos in self.positions.values()
)
def to_dict(self) -> dict:
"""
Export inventory to dictionary format.
Returns:
{
"sats": 100000,
"fiat": {
"EUR": 50.00,
"USD": 60.00
}
}
"""
fiat_balances = self.get_all_fiat_balances()
return {
"sats": int(self.get_balance_sats()),
"fiat": {
curr: float(amount)
for curr, amount in fiat_balances.items()
},
}
def __repr__(self) -> str:
"""String representation for debugging"""
if self.is_empty():
return "CastleInventory(empty)"
positions_str = ", ".join(
f"{curr}: {pos.amount}"
for (curr, _), pos in self.positions.items()
)
return f"CastleInventory({positions_str})"

324
core/validation.py Normal file
View file

@ -0,0 +1,324 @@
"""
Validation rules for Castle accounting.
Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly.
"""
from decimal import Decimal
from typing import Any, Dict, List, Optional
class ValidationError(Exception):
"""Raised when validation fails"""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.message = message
self.details = details or {}
def validate_journal_entry(
entry: Dict[str, Any],
entry_lines: List[Dict[str, Any]]
) -> None:
"""
Validate a journal entry and its lines.
Checks:
1. Entry must have at least 2 lines (double-entry requirement)
2. Entry must be balanced (sum of debits = sum of credits)
3. All lines must have valid amounts (non-negative)
4. All lines must have account_id
Args:
entry: Journal entry dict with keys:
- id: str
- description: str
- entry_date: datetime
entry_lines: List of entry line dicts with keys:
- account_id: str
- debit: int
- credit: int
Raises:
ValidationError: If validation fails
"""
# Check minimum number of lines
if len(entry_lines) < 2:
raise ValidationError(
"Journal entry must have at least 2 lines",
{
"entry_id": entry.get("id"),
"line_count": len(entry_lines),
}
)
# Validate each line
for i, line in enumerate(entry_lines):
# Check account_id exists
if not line.get("account_id"):
raise ValidationError(
f"Entry line {i + 1} missing account_id",
{
"entry_id": entry.get("id"),
"line_index": i,
}
)
# Check amounts are non-negative
debit = line.get("debit", 0)
credit = line.get("credit", 0)
if debit < 0:
raise ValidationError(
f"Entry line {i + 1} has negative debit: {debit}",
{
"entry_id": entry.get("id"),
"line_index": i,
"debit": debit,
}
)
if credit < 0:
raise ValidationError(
f"Entry line {i + 1} has negative credit: {credit}",
{
"entry_id": entry.get("id"),
"line_index": i,
"credit": credit,
}
)
# Check that a line doesn't have both debit and credit
if debit > 0 and credit > 0:
raise ValidationError(
f"Entry line {i + 1} has both debit and credit",
{
"entry_id": entry.get("id"),
"line_index": i,
"debit": debit,
"credit": credit,
}
)
# Check that a line has at least one non-zero amount
if debit == 0 and credit == 0:
raise ValidationError(
f"Entry line {i + 1} has both debit and credit as zero",
{
"entry_id": entry.get("id"),
"line_index": i,
}
)
# Check entry is balanced
total_debits = sum(line.get("debit", 0) for line in entry_lines)
total_credits = sum(line.get("credit", 0) for line in entry_lines)
if total_debits != total_credits:
raise ValidationError(
"Journal entry is not balanced",
{
"entry_id": entry.get("id"),
"total_debits": total_debits,
"total_credits": total_credits,
"difference": total_debits - total_credits,
}
)
def validate_balance(
account_id: str,
expected_balance_sats: int,
actual_balance_sats: int,
tolerance_sats: int = 0,
expected_balance_fiat: Optional[Decimal] = None,
actual_balance_fiat: Optional[Decimal] = None,
tolerance_fiat: Optional[Decimal] = None,
fiat_currency: Optional[str] = None
) -> None:
"""
Validate that actual balance matches expected balance within tolerance.
Args:
account_id: Account being checked
expected_balance_sats: Expected satoshi balance
actual_balance_sats: Actual calculated satoshi balance
tolerance_sats: Allowed difference for sats (±)
expected_balance_fiat: Expected fiat balance (optional)
actual_balance_fiat: Actual fiat balance (optional)
tolerance_fiat: Allowed difference for fiat (±)
fiat_currency: Fiat currency code
Raises:
ValidationError: If balance doesn't match
"""
# Check sats balance
sats_difference = actual_balance_sats - expected_balance_sats
if abs(sats_difference) > tolerance_sats:
raise ValidationError(
f"Balance assertion failed for account {account_id}",
{
"account_id": account_id,
"expected_sats": expected_balance_sats,
"actual_sats": actual_balance_sats,
"difference_sats": sats_difference,
"tolerance_sats": tolerance_sats,
}
)
# Check fiat balance if provided
if expected_balance_fiat is not None and actual_balance_fiat is not None:
if tolerance_fiat is None:
tolerance_fiat = Decimal(0)
fiat_difference = actual_balance_fiat - expected_balance_fiat
if abs(fiat_difference) > tolerance_fiat:
raise ValidationError(
f"Fiat balance assertion failed for account {account_id}",
{
"account_id": account_id,
"currency": fiat_currency,
"expected_fiat": float(expected_balance_fiat),
"actual_fiat": float(actual_balance_fiat),
"difference_fiat": float(fiat_difference),
"tolerance_fiat": float(tolerance_fiat),
}
)
def validate_receivable_entry(
user_id: str,
amount: int,
revenue_account_type: str
) -> None:
"""
Validate a receivable entry (user owes castle).
Args:
user_id: User ID
amount: Amount in sats (must be positive)
revenue_account_type: Must be "revenue"
Raises:
ValidationError: If validation fails
"""
if amount <= 0:
raise ValidationError(
"Receivable amount must be positive",
{"user_id": user_id, "amount": amount}
)
if revenue_account_type != "revenue":
raise ValidationError(
"Receivable must credit a revenue account",
{
"user_id": user_id,
"provided_account_type": revenue_account_type,
}
)
def validate_expense_entry(
user_id: str,
amount: int,
expense_account_type: str,
is_equity: bool
) -> None:
"""
Validate an expense entry (user spent money).
Args:
user_id: User ID
amount: Amount in sats (must be positive)
expense_account_type: Must be "expense" (unless is_equity is True)
is_equity: If True, this is an equity contribution
Raises:
ValidationError: If validation fails
"""
if amount <= 0:
raise ValidationError(
"Expense amount must be positive",
{"user_id": user_id, "amount": amount}
)
if not is_equity and expense_account_type != "expense":
raise ValidationError(
"Expense must debit an expense account",
{
"user_id": user_id,
"provided_account_type": expense_account_type,
}
)
def validate_payment_entry(
user_id: str,
amount: int
) -> None:
"""
Validate a payment entry (user paid their debt).
Args:
user_id: User ID
amount: Amount in sats (must be positive)
Raises:
ValidationError: If validation fails
"""
if amount <= 0:
raise ValidationError(
"Payment amount must be positive",
{"user_id": user_id, "amount": amount}
)
def validate_metadata(
metadata: Dict[str, Any],
required_keys: Optional[List[str]] = None
) -> None:
"""
Validate entry line metadata.
Args:
metadata: Metadata dictionary
required_keys: List of required keys
Raises:
ValidationError: If validation fails
"""
if required_keys:
missing_keys = [key for key in required_keys if key not in metadata]
if missing_keys:
raise ValidationError(
f"Metadata missing required keys: {', '.join(missing_keys)}",
{
"missing_keys": missing_keys,
"provided_keys": list(metadata.keys()),
}
)
# Validate fiat currency and amount consistency
has_fiat_currency = "fiat_currency" in metadata
has_fiat_amount = "fiat_amount" in metadata
if has_fiat_currency != has_fiat_amount:
raise ValidationError(
"fiat_currency and fiat_amount must both be present or both absent",
{
"has_fiat_currency": has_fiat_currency,
"has_fiat_amount": has_fiat_amount,
}
)
# Validate fiat amount is valid Decimal
if has_fiat_amount:
try:
Decimal(str(metadata["fiat_amount"]))
except (ValueError, TypeError) as e:
raise ValidationError(
f"Invalid fiat_amount: {metadata['fiat_amount']}",
{"error": str(e)}
)

168
crud.py
View file

@ -23,6 +23,18 @@ from .models import (
UserWalletSettings,
)
# Import core accounting logic
from .core.balance import BalanceCalculator, AccountType as CoreAccountType
from .core.inventory import CastleInventory, CastlePosition
from .core.validation import (
ValidationError,
validate_journal_entry,
validate_balance,
validate_receivable_entry,
validate_expense_entry,
validate_payment_entry,
)
db = Database("ext_castle")
@ -358,13 +370,11 @@ async def get_account_balance(account_id: str) -> int:
total_debit = result["total_debit"]
total_credit = result["total_credit"]
# Normal balance for each account type:
# Assets and Expenses: Debit balance (debit - credit)
# Liabilities, Equity, and Revenue: Credit balance (credit - debit)
if account.account_type in [AccountType.ASSET, AccountType.EXPENSE]:
return total_debit - total_credit
else:
return total_credit - total_debit
# Use core BalanceCalculator for consistent logic
core_account_type = CoreAccountType(account.account_type.value)
return BalanceCalculator.calculate_account_balance(
total_debit, total_credit, core_account_type
)
async def get_user_balance(user_id: str) -> UserBalance:
@ -376,13 +386,16 @@ async def get_user_balance(user_id: str) -> UserBalance:
Account,
)
total_balance = 0
fiat_balances = {} # Track fiat balances by currency
# Calculate balances for each account
account_balances = {}
account_inventories = {}
for account in user_accounts:
# Get satoshi balance
balance = await get_account_balance(account.id)
account_balances[account.id] = balance
# Get all entry lines for this account to calculate fiat balances
# Get all entry lines for this account to build inventory
# Only include cleared entries (exclude pending/flagged/voided)
entry_lines = await db.fetchall(
"""
@ -395,49 +408,30 @@ async def get_user_balance(user_id: str) -> UserBalance:
{"account_id": account.id},
)
for line in entry_lines:
# Parse metadata to get fiat amounts
metadata = json.loads(line["metadata"]) if line.get("metadata") else {}
fiat_currency = metadata.get("fiat_currency")
fiat_amount = metadata.get("fiat_amount")
# Use BalanceCalculator to build inventory from entry lines
core_account_type = CoreAccountType(account.account_type.value)
inventory = BalanceCalculator.build_inventory_from_entry_lines(
[dict(line) for line in entry_lines],
core_account_type
)
account_inventories[account.id] = inventory
if fiat_currency and fiat_amount:
from decimal import Decimal
# Initialize currency if not exists
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal("0")
# Convert fiat_amount to Decimal
fiat_decimal = Decimal(str(fiat_amount))
# Calculate fiat balance based on account type
if account.account_type == AccountType.LIABILITY:
# Liability: credit increases (castle owes more), debit decreases
if line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_decimal
elif line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_decimal
elif account.account_type == AccountType.ASSET:
# Asset (receivable): debit increases (user owes more), credit decreases
if line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_decimal
elif line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_decimal
# Calculate satoshi balance
# If it's a liability account (castle owes user), it's positive
# If it's an asset account (user owes castle), it's negative
if account.account_type == AccountType.LIABILITY:
total_balance += balance
elif account.account_type == AccountType.ASSET:
total_balance -= balance
# Equity contributions are tracked but don't affect what castle owes
# Use BalanceCalculator to calculate total user balance
accounts_list = [
{"id": acc.id, "account_type": acc.account_type.value}
for acc in user_accounts
]
balance_result = BalanceCalculator.calculate_user_balance(
accounts_list,
account_balances,
account_inventories
)
return UserBalance(
user_id=user_id,
balance=total_balance,
balance=balance_result["balance"],
accounts=user_accounts,
fiat_balances=fiat_balances,
fiat_balances=balance_result["fiat_balances"],
)
@ -450,79 +444,17 @@ async def get_all_user_balances() -> list[UserBalance]:
Account,
)
# Group by user_id
users_dict = {}
for account in all_accounts:
if account.user_id not in users_dict:
users_dict[account.user_id] = []
users_dict[account.user_id].append(account)
# Get unique user IDs
user_ids = set(account.user_id for account in all_accounts if account.user_id)
# Calculate balance for each user
# Calculate balance for each user using the refactored function
user_balances = []
for user_id, accounts in users_dict.items():
total_balance = 0
fiat_balances = {}
for user_id in user_ids:
balance = await get_user_balance(user_id)
for account in accounts:
balance = await get_account_balance(account.id)
# Get all entry lines for this account to calculate fiat balances
# Only include cleared entries (exclude pending/flagged/voided)
entry_lines = await db.fetchall(
"""
SELECT el.*
FROM entry_lines el
JOIN journal_entries je ON el.journal_entry_id = je.id
WHERE el.account_id = :account_id
AND je.flag = '*'
""",
{"account_id": account.id},
)
for line in entry_lines:
# Parse metadata to get fiat amounts
metadata = json.loads(line["metadata"]) if line.get("metadata") else {}
fiat_currency = metadata.get("fiat_currency")
fiat_amount = metadata.get("fiat_amount")
if fiat_currency and fiat_amount:
from decimal import Decimal
# Initialize currency if not exists
if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal("0")
# Convert fiat_amount to Decimal
fiat_decimal = Decimal(str(fiat_amount))
# Calculate fiat balance based on account type
if account.account_type == AccountType.LIABILITY:
# Liability: credit increases (castle owes more), debit decreases
if line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_decimal
elif line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_decimal
elif account.account_type == AccountType.ASSET:
# Asset (receivable): debit increases (user owes more), credit decreases
if line["debit"] > 0:
fiat_balances[fiat_currency] -= fiat_decimal
elif line["credit"] > 0:
fiat_balances[fiat_currency] += fiat_decimal
# Calculate satoshi balance
if account.account_type == AccountType.LIABILITY:
total_balance += balance
elif account.account_type == AccountType.ASSET:
total_balance -= balance
if total_balance != 0 or fiat_balances: # Include users with non-zero balance or fiat balances
user_balances.append(
UserBalance(
user_id=user_id,
balance=total_balance,
accounts=accounts,
fiat_balances=fiat_balances,
)
)
# Include users with non-zero balance or fiat balances
if balance.balance != 0 or balance.fiat_balances:
user_balances.append(balance)
return user_balances