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

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