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
|
|
@ -10,6 +10,7 @@ Underpay without explicit entry-picks returns 400 with diff details so
|
|||
the operator can either pay the exact net or specify `settled_entry_links`.
|
||||
"""
|
||||
import importlib
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
|
@ -268,10 +269,12 @@ async def test_underpay_without_explicit_links_returns_400(
|
|||
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
||||
payload = r.json().get("detail")
|
||||
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
|
||||
assert payload.get("cash_paid") == 30.0
|
||||
assert payload.get("net_obligation") == 100.0
|
||||
assert payload.get("receivable_total") == 100.0
|
||||
assert payload.get("payable_total") == 0.0
|
||||
# Amounts are exact Decimal strings (not floats) so the operator can
|
||||
# act on them without precision loss.
|
||||
assert Decimal(payload.get("cash_paid")) == Decimal("30.00")
|
||||
assert Decimal(payload.get("net_obligation")) == Decimal("100.00")
|
||||
assert Decimal(payload.get("receivable_total")) == Decimal("100.00")
|
||||
assert Decimal(payload.get("payable_total")) == Decimal("0")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue