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
324
core/validation.py
Normal file
324
core/validation.py
Normal 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)}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue