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
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue