libra/core/validation.py
Padreug ec6cac51f0 chore: hygiene sweep — dead code, role race, user lookup, stale files
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>
2026-07-12 16:01:34 +02:00

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)}
)