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>
When the caller omits settled_entry_links (the default), the endpoint
auto-detects open entries across both directions for the user and writes
a single transaction that:
- Zeros every per-user account that has an open balance, not just the
net (the libra-#33 bug — previously the 2-leg form left both Payable
and Receivable carrying non-zero balances after a complete cash
settlement, while only netting the cash side).
- Routes any cash above the net obligation to Liabilities:Credit:User-X
(libra-#41), so over-payment lands on a real liability account
instead of silently drifting.
- Attaches every reconciled source entry's link
(exp-..., rcv-...) so a reader scanning the settlement transaction
can trace what it cleared.
Cash less than the net obligation, with no explicit links, returns 400
with a structured diff (cash_paid, net_obligation, receivable_total,
payable_total). The operator either pays the exact net or passes
settled_entry_links to settle a specific subset; partial settlement
without a coherent target is not silently absorbed.
The legacy explicit-links code path is unchanged — callers that pass
settled_entry_links keep the 2-leg shape with no auto-detection. None
of the callers in libra or aiolabs/webapp currently use that field, but
the contract is preserved for the partial-settle-of-specific-entries
flow.
format_fiat_net_settlement_entry is the new helper for the 2/3/4-leg
shape; it enforces the cash-balance constraint inline so callers can't
accidentally produce an unbalanced transaction.
tests/test_settlement_api.py (6 tests) locks in:
- Nancy's #33 scenario: receivable 100 + payable 50 + cash 50
zeros both per-user accounts, links both source entries
- Overpay: cash 70 against net 50 → credit balance 20
- Pure receivable overpay → credit appears
- Underpay without explicit links → 400 with diff
- No open receivables → 400 with hint pointing at /payables/pay
- Explicit settled_entry_links uses legacy 2-leg path
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>