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>
This commit is contained in:
Padreug 2026-07-12 12:54:46 +02:00
commit cf1a0967bf
7 changed files with 276 additions and 68 deletions

View file

@ -5,7 +5,7 @@ Comprehensive validation following Beancount's plugin system approach,
but implemented as simple functions that can be called directly.
"""
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional
@ -278,11 +278,14 @@ def validate_metadata(
}
)
# Validate fiat amount is valid Decimal
# 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) as e:
except (ValueError, TypeError, InvalidOperation) as e:
raise ValidationError(
f"Invalid fiat_amount: {metadata['fiat_amount']}",
{"error": str(e)}