One pass over the LOW-tier review items plus two folded issues: - Delete validate_journal_entry (dead since the Fava migration; it validated the pre-string-amount model) with its exports, unused crud imports, and tests. Beancount validates entries now. - Migration m006: UNIQUE index on user_roles(user_id, role_id) after deduping; assign_user_role inserts with ON CONFLICT DO NOTHING and returns the existing assignment — closes the auto-assign check-then-act race on concurrent logins. - Extract _get_username_from_user_id (110 lines in views_api, fresh LNbits Database per call inside per-row hot paths) into user_lookup.py with one shared core-DB handle, a 60s TTL cache and a batch get_usernames API (review #18). - Receivable-entry responses report CLEARED, matching the flag the formatter actually writes; PENDING misled the UI (libra-#35). - Replace the remaining print() calls in tasks.py with logger. - get_all_accounts derives valid roots from account_utils.ACCOUNT_TYPE_ROOTS instead of a hardcoded tuple, and the no-op per-test rate-limit reset is gone (libra-#54). - Delete migrations_old.py.bak, MIGRATION_SQUASH_SUMMARY.md, docs/PHASE*_COMPLETE.md and the rendered .html; .gitignore data/ (it holds the runtime .lnbits_auth_key secret). - Track docs/CODE-REVIEW-2026-06.md with finding statuses updated for the PR #55-#59 + chore/hygiene series. - CLAUDE.md notes LNbits pins Pydantic v1: keep .dict(), don't "modernize" to .model_dump(). Note: format_payment_entry's is_payable docstring (flagged in review follow-up) turned out to be consistent with the body — no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
6.5 KiB
Python
217 lines
6.5 KiB
Python
"""
|
|
Validation rules for Libra accounting.
|
|
|
|
Comprehensive validation following Beancount's plugin system approach,
|
|
but implemented as simple functions that can be called directly.
|
|
"""
|
|
|
|
from decimal import Decimal, InvalidOperation
|
|
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_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 libra).
|
|
|
|
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. InvalidOperation is what
|
|
# Decimal actually raises on garbage input ("abc") — it is not a
|
|
# ValueError subclass, so without it the raw exception leaked to
|
|
# callers (libra-#38).
|
|
if has_fiat_amount:
|
|
try:
|
|
Decimal(str(metadata["fiat_amount"]))
|
|
except (ValueError, TypeError, InvalidOperation) as e:
|
|
raise ValidationError(
|
|
f"Invalid fiat_amount: {metadata['fiat_amount']}",
|
|
{"error": str(e)}
|
|
)
|