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`.
324 lines
9.6 KiB
Python
324 lines
9.6 KiB
Python
"""
|
|
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)}
|
|
)
|