Full identifier rename: module path lnbits.extensions.castle →
lnbits.extensions.libra, DB ext_castle → ext_libra, URL prefix
/castle/ → /libra/, manifest id castle → libra, fava ledger slug
default castle-ledger → libra-ledger, Beancount source metadata
castle-api → libra-api and link prefixes castle-{entry,tx}- →
libra-{entry,tx}-, column castle_wallet_id → libra_wallet_id, all
Python/JS/HTML identifiers (castle_ext, CastleSettings,
castle_reference, castleWalletConfigured, etc.).
Display name "Castle Accounting" → "Libra" (the scales/balance
metaphor — fits double-entry bookkeeping).
No backward compat: production hosts will be force-updated. Old
castle-prefixed Beancount metadata in existing Fava ledgers is
historical; new entries use libra-* prefixes going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
8.7 KiB
Python
289 lines
8.7 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
|
|
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
|
|
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)}
|
|
)
|