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:
parent
50658440a4
commit
116df46d38
3 changed files with 580 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue