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:
parent
6d84479f7d
commit
9c0bdc58eb
7 changed files with 1204 additions and 123 deletions
228
core/balance.py
Normal file
228
core/balance.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue