libra/core/validation.py
Padreug cf1a0967bf fix(settlement): per-currency netting, balance guards, Decimal rates
Settlement correctness cluster from CODE-REVIEW-2026-06 (#2, #8, #13)
plus libra-#38:

- format_net_settlement_entry now enforces the same inline balance
  constraint as the fiat formatter (payment = receivable - payable
  + credit) and grows an optional credit leg. An unbalanced
  settlement raises instead of reaching the ledger.
- on_invoice_paid settles only what the payment covers: a partial
  payment clears that much receivable; excess (or a payment with
  nothing owed) becomes user credit. Previously the full prior
  balance was cleared against a smaller payment, shipping unbalanced
  postings. Settlement links are attached only when the payment
  clears the full open balance, and only for same-currency entries.
- get_unsettled_entries_bql returns each entry's real posting
  currency (was hardcoded "EUR") and exact Decimal amount strings
  (was float). /receivables/settle nets only entries denominated in
  the settlement currency.
- fiat_rate/btc_rate metadata computed via Decimal (new
  fiat_rate_metadata helper) instead of float division — cost-basis
  records no longer carry float drift.
- format_posting_at_average_cost omits the cost braces when
  cost_currency is unset ("SATS {}" is invalid Beancount).
- Underpay error payload serializes amounts as exact Decimal strings.
- validate_metadata catches decimal.InvalidOperation so bad
  fiat_amount input becomes ValidationError (libra-#38); flipped the
  tracking xfail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:54:46 +02:00

292 lines
8.9 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_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 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)}
)