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`.
228 lines
7.9 KiB
Python
228 lines
7.9 KiB
Python
"""
|
|
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
|