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

@ -553,11 +553,6 @@ def test_validate_metadata_fiat_amount_without_currency_raises():
val.validate_metadata({"fiat_amount": "10.00"})
@pytest.mark.xfail(
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
strict=True,
)
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
@ -570,3 +565,111 @@ def test_validate_metadata_both_present_passes():
def test_validate_metadata_neither_present_passes():
val.validate_metadata({"source": "api"})
# ---------------------------------------------------------------------------
# format_net_settlement_entry — balance guard + credit overflow
# ---------------------------------------------------------------------------
def _net_settlement(**overrides):
kwargs = dict(
user_id="abc12345",
payment_account="Assets:Bitcoin:Lightning",
receivable_account="Assets:Receivable:User-abc12345",
payable_account="Liabilities:Payable:User-abc12345",
amount_sats=565251,
net_fiat_amount=Decimal("517.00"),
total_receivable_fiat=Decimal("555.00"),
total_payable_fiat=Decimal("38.00"),
fiat_currency="EUR",
description="test settlement",
entry_date=date(2026, 7, 12),
payment_hash="ff" * 32,
)
kwargs.update(overrides)
return bf.format_net_settlement_entry(**kwargs)
def test_net_settlement_balanced_passes():
entry = _net_settlement()
amounts = [p["amount"] for p in entry["postings"]]
assert any("@@ 517.00 EUR" in a for a in amounts)
assert "-555.00 EUR" in amounts
assert "38.00 EUR" in amounts
def test_net_settlement_unbalanced_partial_payment_raises():
# Payment of 300 can't clear a 555 receivable net of 38 payable —
# this is the pre-fix partial-payment shape that shipped unbalanced
# postings to the ledger.
with pytest.raises(ValueError, match="unbalanced"):
_net_settlement(net_fiat_amount=Decimal("300.00"))
def test_net_settlement_negative_amount_raises():
with pytest.raises(ValueError, match="non-negative"):
_net_settlement(total_receivable_fiat=Decimal("-1.00"))
def test_net_settlement_credit_overflow_adds_leg():
entry = _net_settlement(
net_fiat_amount=Decimal("600.00"),
credit_account="Liabilities:Credit:User-abc12345",
credit_overflow_fiat=Decimal("83.00"),
)
amounts = [p["amount"] for p in entry["postings"]]
assert "-83.00 EUR" in amounts
def test_net_settlement_credit_overflow_without_account_raises():
with pytest.raises(ValueError, match="credit_account"):
_net_settlement(
net_fiat_amount=Decimal("600.00"),
credit_overflow_fiat=Decimal("83.00"),
)
# ---------------------------------------------------------------------------
# format_posting_at_average_cost — no empty cost braces
# ---------------------------------------------------------------------------
def test_average_cost_posting_without_currency_omits_braces():
posting = bf.format_posting_at_average_cost(
account="Assets:Receivable:User-abc", amount_sats=-996896,
)
assert posting["amount"] == "-996896 SATS"
assert "{" not in posting["amount"]
def test_average_cost_posting_with_currency_keeps_braces():
posting = bf.format_posting_at_average_cost(
account="Assets:Receivable:User-abc",
amount_sats=-996896,
cost_currency="EUR",
)
assert posting["amount"] == "-996896 SATS {EUR}"
# ---------------------------------------------------------------------------
# fiat_rate_metadata — Decimal-exact rate strings
# ---------------------------------------------------------------------------
def test_fiat_rate_metadata_is_exact():
meta = bf.fiat_rate_metadata(107419, Decimal("100.00"))
assert meta["fiat_rate"] == "1074.190000"
assert meta["btc_rate"] == "93093.40"
# No float artifacts like 1074.1899999999998
Decimal(meta["fiat_rate"])
Decimal(meta["btc_rate"])
def test_fiat_rate_metadata_zero_amounts():
assert bf.fiat_rate_metadata(0, Decimal("100.00")) == {
"fiat_rate": "0", "btc_rate": "0",
}
assert bf.fiat_rate_metadata(1000, Decimal("0")) == {
"fiat_rate": "0", "btc_rate": "0",
}