Refactors the data model to use a single 'amount' field for journal entry lines, aligning with the Beancount approach. This simplifies the model, enhances compatibility, and eliminates invalid states. Includes a database migration to convert existing debit/credit columns to the new 'amount' field. Updates balance calculation logic to utilize the new amount field for improved accuracy and efficiency.
289 lines
8.7 KiB
Python
289 lines
8.7 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 (Beancount-style with single amount field).
|
|
|
|
Checks:
|
|
1. Entry must have at least 2 lines (double-entry requirement)
|
|
2. Entry must be balanced (sum of amounts = 0)
|
|
3. All lines must have account_id
|
|
4. No line should have amount = 0 (would serve no purpose)
|
|
|
|
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
|
|
- amount: int (positive = debit, negative = credit)
|
|
|
|
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,
|
|
}
|
|
)
|
|
|
|
# Get amount (Beancount-style: positive = debit, negative = credit)
|
|
amount = line.get("amount", 0)
|
|
|
|
# Check that amount is non-zero (zero amounts serve no purpose)
|
|
if amount == 0:
|
|
raise ValidationError(
|
|
f"Entry line {i + 1} has amount = 0 (serves no purpose)",
|
|
{
|
|
"entry_id": entry.get("id"),
|
|
"line_index": i,
|
|
}
|
|
)
|
|
|
|
# Check entry is balanced (sum of amounts must equal 0)
|
|
# Beancount-style: positive amounts cancel out negative amounts
|
|
total_amount = sum(line.get("amount", 0) for line in entry_lines)
|
|
|
|
if total_amount != 0:
|
|
raise ValidationError(
|
|
"Journal entry is not balanced (sum of amounts must equal 0)",
|
|
{
|
|
"entry_id": entry.get("id"),
|
|
"total_amount": total_amount,
|
|
"line_count": len(entry_lines),
|
|
}
|
|
)
|
|
|
|
|
|
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)}
|
|
)
|