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:
parent
44e10caac7
commit
cf1a0967bf
7 changed files with 276 additions and 68 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue