Net settlement + credit overflow on /receivables/settle (libra-#33, libra-#41)

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>
This commit is contained in:
Padreug 2026-06-07 14:51:43 +02:00
commit 116df46d38
3 changed files with 580 additions and 4 deletions

View file

@ -804,6 +804,139 @@ def format_net_settlement_entry(
)
def format_fiat_net_settlement_entry(
user_id: str,
cash_account: str,
receivable_account: str,
payable_account: Optional[str],
credit_account: Optional[str],
cash_paid_fiat: Decimal,
total_receivable_fiat: Decimal,
total_payable_fiat: Decimal,
credit_overflow_fiat: Decimal,
fiat_currency: str,
description: str,
entry_date: date,
payment_method: str = "cash",
reference: Optional[str] = None,
settled_entry_links: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Fiat cash settlement that nets receivable and payable for one user.
Implements the contract from libra-#33 (settlement netting) and
libra-#41 (credit-balance overflow). Builds a 2- to 4-leg transaction
depending on what the user has open:
- Cash + Receivable only (2-leg) pure receivable, exact pay
- Cash + Receivable + Credit (3-leg) overpay against pure receivable
- Cash + Receivable + Payable (3-leg) nancy's #33 scenario, exact pay
- Cash + Receivable + Payable + Credit (4-leg) net + overpay
The receivable leg is always present (this endpoint is `/receivables/settle`).
The payable leg appears when the user has open expenses being netted against
the receivable. The credit leg appears when cash > settle target, absorbing
the overflow as a liability libra owes the user going forward.
Constraint enforced inline:
cash_paid_fiat = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
Args:
user_id: User ID
cash_account: Payment-method account name (e.g. "Assets:Cash")
receivable_account: User's receivable account being cleared
payable_account: User's payable account being cleared (omit when no payable)
credit_account: User's credit account receiving overflow (omit when no overflow)
cash_paid_fiat: What the user paid in cash, unsigned
total_receivable_fiat: Gross receivable being cleared (unsigned, 0 if none)
total_payable_fiat: Gross payable being cleared (unsigned, 0 if none)
credit_overflow_fiat: Excess cash going to credit (unsigned, 0 if none)
fiat_currency: Currency code (EUR, USD, etc.)
description: Entry narration
entry_date: Date of settlement
payment_method: cash / bank_transfer / check / other
reference: Optional caller-supplied reference (becomes an extra link)
settled_entry_links: Source entry links being cleared
(e.g. `["exp-abc", "rcv-def"]`). The audit trail for which
originals this settlement reconciles.
Returns:
Fava API entry dict ready for `fava.add_entry`.
Raises:
ValueError: if any amount is negative, or if the cash-balance
constraint above is not satisfied.
"""
for label, value in (
("cash_paid_fiat", cash_paid_fiat),
("total_receivable_fiat", total_receivable_fiat),
("total_payable_fiat", total_payable_fiat),
("credit_overflow_fiat", credit_overflow_fiat),
):
if value < 0:
raise ValueError(f"{label} must be non-negative; got {value}")
expected_cash = total_receivable_fiat - total_payable_fiat + credit_overflow_fiat
if abs(cash_paid_fiat - expected_cash) > Decimal("0.01"):
raise ValueError(
f"cash_paid_fiat {cash_paid_fiat} does not match expected "
f"{expected_cash} (= receivable {total_receivable_fiat} "
f"- payable {total_payable_fiat} + credit {credit_overflow_fiat})"
)
if total_payable_fiat > 0 and not payable_account:
raise ValueError("payable_account required when total_payable_fiat > 0")
if credit_overflow_fiat > 0 and not credit_account:
raise ValueError("credit_account required when credit_overflow_fiat > 0")
postings: List[Dict[str, Any]] = [
{"account": cash_account, "amount": f"{cash_paid_fiat:.2f} {fiat_currency}"},
{"account": receivable_account, "amount": f"-{total_receivable_fiat:.2f} {fiat_currency}"},
]
if total_payable_fiat > 0:
postings.append({
"account": payable_account,
"amount": f"{total_payable_fiat:.2f} {fiat_currency}",
})
if credit_overflow_fiat > 0:
postings.append({
"account": credit_account,
"amount": f"-{credit_overflow_fiat:.2f} {fiat_currency}",
})
payment_method_map = {
"cash": ("cash_settlement", "cash-payment"),
"bank_transfer": ("bank_settlement", "bank-transfer"),
"check": ("check_settlement", "check-payment"),
"btc_onchain": ("onchain_settlement", "onchain-payment"),
"other": ("manual_settlement", "manual-payment"),
}
source, tag = payment_method_map.get(
payment_method.lower(), ("manual_settlement", "manual-payment"),
)
entry_meta: Dict[str, Any] = {
"user-id": user_id,
"source": source,
"payment-type": "net-settlement",
}
links: List[str] = []
if settled_entry_links:
links.extend(settled_entry_links)
if reference:
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,
flag="*",
narration=description,
postings=postings,
tags=[tag, "settlement", "net-settlement"],
links=links,
meta=entry_meta,
)
def format_revenue_entry(
payment_account: str,
revenue_account: str,