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
109
views_api.py
109
views_api.py
|
|
@ -1992,7 +1992,11 @@ async def api_settle_receivable(
|
|||
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
|
||||
# This records that user paid their debt
|
||||
from .fava_client import get_fava_client
|
||||
from .beancount_format import format_payment_entry, format_fiat_settlement_entry
|
||||
from .beancount_format import (
|
||||
format_payment_entry,
|
||||
format_fiat_settlement_entry,
|
||||
format_fiat_net_settlement_entry,
|
||||
)
|
||||
from decimal import Decimal
|
||||
|
||||
fava = get_fava_client()
|
||||
|
|
@ -2002,9 +2006,106 @@ async def api_settle_receivable(
|
|||
"cash", "bank_transfer", "check", "other"
|
||||
]
|
||||
|
||||
if is_fiat_payment:
|
||||
# Fiat currency payment (cash, bank transfer, etc.)
|
||||
# Record in fiat currency with sats as metadata
|
||||
if is_fiat_payment and data.settled_entry_links is None:
|
||||
# Auto-detect netting + credit-overflow path (libra-#33 + libra-#41).
|
||||
# The operator hasn't picked specific entries — backend nets all
|
||||
# open balances in both directions, validates cash matches the net
|
||||
# obligation (or absorbs excess into credit), and writes a single
|
||||
# transaction that links every reconciled source entry.
|
||||
|
||||
unsettled_payables = await fava.get_unsettled_entries_bql(data.user_id, "expense")
|
||||
unsettled_receivables = await fava.get_unsettled_entries_bql(data.user_id, "receivable")
|
||||
|
||||
payable_total = sum(
|
||||
(Decimal(str(e["fiat_amount"])) for e in unsettled_payables),
|
||||
Decimal(0),
|
||||
)
|
||||
receivable_total = sum(
|
||||
(Decimal(str(e["fiat_amount"])) for e in unsettled_receivables),
|
||||
Decimal(0),
|
||||
)
|
||||
all_links = (
|
||||
[e["link"] for e in unsettled_payables if e.get("link")]
|
||||
+ [e["link"] for e in unsettled_receivables if e.get("link")]
|
||||
)
|
||||
|
||||
if receivable_total <= 0:
|
||||
# Endpoint is `/receivables/settle` — user paying off something
|
||||
# they owe. With no open receivable there's nothing this endpoint
|
||||
# can settle. Operator should use `/payables/pay` (libra pays user)
|
||||
# or wait until the user has open receivables.
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=(
|
||||
f"User {data.user_id[:8]} has no open receivables to settle. "
|
||||
f"If libra owes them, use `/payables/pay`. If they want to "
|
||||
f"deposit credit without an open obligation, that's a future "
|
||||
f"feature (libra-#41 follow-up)."
|
||||
),
|
||||
)
|
||||
|
||||
cash_paid = Decimal(str(data.amount))
|
||||
net_obligation = receivable_total - payable_total
|
||||
tolerance = Decimal("0.01") # forex rounding slack
|
||||
|
||||
if cash_paid + tolerance < net_obligation:
|
||||
# Under-pay without explicit entry-picks — backend can't guess
|
||||
# which receivable(s) the operator means to settle.
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail={
|
||||
"message": (
|
||||
"Cash paid is less than net obligation. Pay the exact "
|
||||
"net to clear all open entries, or pass "
|
||||
"`settled_entry_links` to settle a specific subset."
|
||||
),
|
||||
"cash_paid": float(cash_paid),
|
||||
"net_obligation": float(net_obligation),
|
||||
"receivable_total": float(receivable_total),
|
||||
"payable_total": float(payable_total),
|
||||
"currency": data.currency.upper(),
|
||||
},
|
||||
)
|
||||
|
||||
credit_overflow = cash_paid - net_obligation
|
||||
if credit_overflow < tolerance:
|
||||
credit_overflow = Decimal(0)
|
||||
|
||||
# Auto-create the user-side accounts as needed.
|
||||
user_payable = None
|
||||
if payable_total > 0:
|
||||
user_payable = await get_or_create_user_account(
|
||||
data.user_id, AccountType.LIABILITY, "Accounts Payable",
|
||||
)
|
||||
user_credit = None
|
||||
if credit_overflow > 0:
|
||||
user_credit = await get_or_create_user_account(
|
||||
data.user_id, AccountType.LIABILITY, "Credit",
|
||||
)
|
||||
|
||||
entry = format_fiat_net_settlement_entry(
|
||||
user_id=data.user_id,
|
||||
cash_account=payment_account.name,
|
||||
receivable_account=user_receivable.name,
|
||||
payable_account=user_payable.name if user_payable else None,
|
||||
credit_account=user_credit.name if user_credit else None,
|
||||
cash_paid_fiat=cash_paid,
|
||||
total_receivable_fiat=receivable_total,
|
||||
total_payable_fiat=payable_total,
|
||||
credit_overflow_fiat=credit_overflow,
|
||||
fiat_currency=data.currency.upper(),
|
||||
description=data.description,
|
||||
entry_date=datetime.now().date(),
|
||||
payment_method=data.payment_method,
|
||||
reference=data.reference or f"MANUAL-{data.user_id[:8]}",
|
||||
settled_entry_links=all_links,
|
||||
)
|
||||
elif is_fiat_payment:
|
||||
# Legacy fiat path — operator provided `settled_entry_links` explicitly,
|
||||
# meaning they're settling a specific subset. Backwards-compatible
|
||||
# 2-leg behaviour: trust the caller's list, no auto-netting, no
|
||||
# credit-overflow validation. Use the auto-detect path above (omit
|
||||
# settled_entry_links) to get netting + credit handling.
|
||||
if not data.amount_sats:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue