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(
|
def format_revenue_entry(
|
||||||
payment_account: str,
|
payment_account: str,
|
||||||
revenue_account: str,
|
revenue_account: str,
|
||||||
|
|
|
||||||
342
tests/test_settlement_api.py
Normal file
342
tests/test_settlement_api.py
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
"""Settlement netting + credit overflow — libra-#33 + libra-#41.
|
||||||
|
|
||||||
|
`POST /libra/api/v1/receivables/settle` with `settled_entry_links=None`
|
||||||
|
(the default) auto-detects open entries in both directions, builds a
|
||||||
|
3-leg settlement transaction that zeros out both per-user accounts when
|
||||||
|
the user has open balances on both sides (libra-#33's nancy scenario),
|
||||||
|
and routes any excess cash to `Liabilities:Credit:User-X` (libra-#41).
|
||||||
|
|
||||||
|
Underpay without explicit entry-picks returns 400 with diff details so
|
||||||
|
the operator can either pay the exact net or specify `settled_entry_links`.
|
||||||
|
"""
|
||||||
|
import importlib
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
approve_entry,
|
||||||
|
get_balance,
|
||||||
|
list_user_entries,
|
||||||
|
post_expense,
|
||||||
|
post_receivable,
|
||||||
|
settle_receivable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _libra_module(submodule: str):
|
||||||
|
for prefix in ("lnbits.extensions.libra", "libra"):
|
||||||
|
try:
|
||||||
|
return importlib.import_module(f"{prefix}.{submodule}")
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
continue
|
||||||
|
raise ModuleNotFoundError(f"libra.{submodule}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _approve_and_refresh(client, wallet, super_user_headers, entry_id):
|
||||||
|
"""Approve a pending entry and force a Fava reload (libra-#37 workaround)."""
|
||||||
|
await approve_entry(client, super_user_headers=super_user_headers, entry_id=entry_id)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nancy's #33 scenario and variants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_exact_net_settlement_zeroes_both_per_user_accounts(
|
||||||
|
client, super_user_headers, configured_user, standard_accounts,
|
||||||
|
):
|
||||||
|
"""Nancy: receivable 100 EUR + payable 50 EUR + 50 EUR cash → 3-leg
|
||||||
|
settlement that zeros both Receivable and Payable for this user.
|
||||||
|
|
||||||
|
Acceptance criteria from libra-#33:
|
||||||
|
- Settlement links every source entry it reconciles.
|
||||||
|
- Per-user balances drop to 0 (not just net to 0 leaving each side open).
|
||||||
|
"""
|
||||||
|
user, wallet = configured_user
|
||||||
|
tag = uuid4().hex[:6]
|
||||||
|
|
||||||
|
# Admin records the receivable (cleared on creation).
|
||||||
|
await post_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="100.00", currency="EUR",
|
||||||
|
description=f"Rent share {tag}",
|
||||||
|
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||||
|
)
|
||||||
|
# User submits an expense (pending until admin approves).
|
||||||
|
exp = await post_expense(
|
||||||
|
client,
|
||||||
|
wallet_inkey=wallet.inkey,
|
||||||
|
user_wallet_id=wallet.id,
|
||||||
|
amount="50.00", currency="EUR",
|
||||||
|
description=f"Drill purchase {tag}",
|
||||||
|
expense_account=standard_accounts["expense_food"]["name"],
|
||||||
|
)
|
||||||
|
await _approve_and_refresh(client, wallet, super_user_headers, exp["id"])
|
||||||
|
|
||||||
|
# Sanity check: user owes 50 EUR net (100 receivable - 50 payable).
|
||||||
|
balance_before = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||||
|
eur_before = balance_before.get("fiat_balances", {}).get("EUR")
|
||||||
|
assert float(eur_before) == pytest.approx(50.0), (
|
||||||
|
f"expected +50 EUR net (user owes libra), got {eur_before}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Settle the net cash: 50 EUR.
|
||||||
|
await settle_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="50.00", currency="EUR",
|
||||||
|
description=f"Cash settlement {tag}",
|
||||||
|
)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
# After settlement: net balance is 0.
|
||||||
|
balance_after = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||||
|
eur_after = balance_after.get("fiat_balances", {}).get("EUR", 0)
|
||||||
|
assert float(eur_after or 0) == pytest.approx(0.0), (
|
||||||
|
f"expected 0 EUR after exact net settlement, got {eur_after}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-account breakdown: every user-side account is at 0.
|
||||||
|
# (The acceptance criterion is that NEITHER Receivable nor Payable
|
||||||
|
# carries an open balance — not just that they net to 0.)
|
||||||
|
breakdown = balance_after.get("account_balances", [])
|
||||||
|
for row in breakdown:
|
||||||
|
if user.id[:8] in (row.get("account") or ""):
|
||||||
|
assert float(row.get("eur", 0) or 0) == pytest.approx(0.0), (
|
||||||
|
f"per-user account {row['account']} still has "
|
||||||
|
f"{row.get('eur')} EUR open after complete settlement; "
|
||||||
|
f"libra-#33 acceptance criterion violated"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The settlement entry's links must cover both source entries.
|
||||||
|
# Both rcv-* and exp-* links should appear via Fava query.
|
||||||
|
fava_client_mod = _libra_module("fava_client")
|
||||||
|
fava = fava_client_mod.get_fava_client()
|
||||||
|
unsettled_receivables = await fava.get_unsettled_entries_bql(user.id, "receivable")
|
||||||
|
unsettled_payables = await fava.get_unsettled_entries_bql(user.id, "expense")
|
||||||
|
assert not unsettled_receivables, (
|
||||||
|
f"receivable left as unsettled after complete settlement: "
|
||||||
|
f"{unsettled_receivables}"
|
||||||
|
)
|
||||||
|
assert not unsettled_payables, (
|
||||||
|
f"payable left as unsettled after complete settlement: "
|
||||||
|
f"{unsettled_payables}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_overpay_routes_excess_to_credit(
|
||||||
|
client, super_user_headers, configured_user, standard_accounts,
|
||||||
|
):
|
||||||
|
"""Receivable 100 + payable 50 + cash 70 EUR → settles both per-user
|
||||||
|
accounts to 0, and the 20 EUR excess lands on Liabilities:Credit:User-X
|
||||||
|
(libra now owes the user 20 going forward).
|
||||||
|
|
||||||
|
Headline libra-#41 case: cash > net obligation absorbed into credit.
|
||||||
|
"""
|
||||||
|
user, wallet = configured_user
|
||||||
|
tag = uuid4().hex[:6]
|
||||||
|
|
||||||
|
await post_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="100.00", currency="EUR",
|
||||||
|
description=f"Receivable {tag}",
|
||||||
|
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||||
|
)
|
||||||
|
exp = await post_expense(
|
||||||
|
client,
|
||||||
|
wallet_inkey=wallet.inkey,
|
||||||
|
user_wallet_id=wallet.id,
|
||||||
|
amount="50.00", currency="EUR",
|
||||||
|
description=f"Payable {tag}",
|
||||||
|
expense_account=standard_accounts["expense_food"]["name"],
|
||||||
|
)
|
||||||
|
await _approve_and_refresh(client, wallet, super_user_headers, exp["id"])
|
||||||
|
|
||||||
|
# User pays 70 EUR — 20 EUR over the 50 EUR net obligation.
|
||||||
|
await settle_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="70.00", currency="EUR",
|
||||||
|
description=f"Overpay settlement {tag}",
|
||||||
|
)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
# Net balance should be -20 EUR (libra owes user 20 via credit).
|
||||||
|
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||||
|
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||||
|
assert float(eur) == pytest.approx(-20.0), (
|
||||||
|
f"expected -20 EUR (libra owes user via credit), got {eur} from {balance}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Credit account should appear in the breakdown with -20 EUR.
|
||||||
|
breakdown = balance.get("account_balances", [])
|
||||||
|
credit_row = next(
|
||||||
|
(r for r in breakdown if "Credit" in (r.get("account") or "")), None,
|
||||||
|
)
|
||||||
|
assert credit_row is not None, (
|
||||||
|
f"Credit account missing from breakdown: {breakdown}"
|
||||||
|
)
|
||||||
|
assert float(credit_row.get("eur", 0)) == pytest.approx(-20.0), (
|
||||||
|
f"expected -20 EUR on Credit:User-X, got {credit_row.get('eur')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_pure_receivable_overpay_creates_credit(
|
||||||
|
client, super_user_headers, configured_user, standard_accounts,
|
||||||
|
):
|
||||||
|
"""No payable side — receivable 50 + cash 70 → receivable cleared,
|
||||||
|
20 EUR moves to credit. 2-leg + credit overflow leg."""
|
||||||
|
user, wallet = configured_user
|
||||||
|
tag = uuid4().hex[:6]
|
||||||
|
|
||||||
|
await post_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="50.00", currency="EUR",
|
||||||
|
description=f"Pure receivable {tag}",
|
||||||
|
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||||
|
)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
await settle_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="70.00", currency="EUR",
|
||||||
|
description=f"Pure overpay {tag}",
|
||||||
|
)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
balance = await get_balance(client, wallet_inkey=wallet.inkey)
|
||||||
|
eur = balance.get("fiat_balances", {}).get("EUR")
|
||||||
|
# Receivable cleared (0) - credit (-20) = -20 net
|
||||||
|
assert float(eur) == pytest.approx(-20.0), (
|
||||||
|
f"expected -20 EUR after pure overpay, got {eur}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation: underpay without explicit links → 400 with diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_underpay_without_explicit_links_returns_400(
|
||||||
|
client, super_user_headers, configured_user, standard_accounts,
|
||||||
|
):
|
||||||
|
"""Cash < net obligation and no `settled_entry_links` → 400 with the
|
||||||
|
diff payload so operator can fix the amount or specify entries.
|
||||||
|
|
||||||
|
Without #41's credit overflow + #33's auto-detect, this was the
|
||||||
|
silent-drift case that motivated both issues. Now: explicit, recoverable.
|
||||||
|
"""
|
||||||
|
user, wallet = configured_user
|
||||||
|
|
||||||
|
await post_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="100.00", currency="EUR",
|
||||||
|
description="Receivable to underpay against",
|
||||||
|
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||||
|
)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
"/libra/api/v1/receivables/settle",
|
||||||
|
headers=super_user_headers,
|
||||||
|
json={
|
||||||
|
"user_id": user.id,
|
||||||
|
"amount": "30.00",
|
||||||
|
"currency": "EUR",
|
||||||
|
"payment_method": "cash",
|
||||||
|
"description": "Underpay attempt",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
||||||
|
payload = r.json().get("detail")
|
||||||
|
assert isinstance(payload, dict), f"expected structured detail, got {payload!r}"
|
||||||
|
assert payload.get("cash_paid") == 30.0
|
||||||
|
assert payload.get("net_obligation") == 100.0
|
||||||
|
assert payload.get("receivable_total") == 100.0
|
||||||
|
assert payload.get("payable_total") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_open_receivable_returns_400(
|
||||||
|
client, super_user_headers, configured_user,
|
||||||
|
):
|
||||||
|
"""User has no open receivables → endpoint can't settle. 400 with a
|
||||||
|
hint pointing at `/payables/pay` for the inverse direction."""
|
||||||
|
user, _ = configured_user
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
"/libra/api/v1/receivables/settle",
|
||||||
|
headers=super_user_headers,
|
||||||
|
json={
|
||||||
|
"user_id": user.id,
|
||||||
|
"amount": "50.00",
|
||||||
|
"currency": "EUR",
|
||||||
|
"payment_method": "cash",
|
||||||
|
"description": "Random deposit attempt",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
|
||||||
|
assert "no open receivables" in r.text.lower() or "payables/pay" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Legacy explicit-links path: preserved for partial-settle-of-specific-entries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_explicit_settled_entry_links_uses_legacy_2_leg_path(
|
||||||
|
client, super_user_headers, configured_user, standard_accounts,
|
||||||
|
):
|
||||||
|
"""When `settled_entry_links` is provided, backend trusts the caller's
|
||||||
|
list and writes the legacy 2-leg shape. No auto-netting, no credit
|
||||||
|
overflow validation. Required for callers that want to settle a
|
||||||
|
specific subset of entries.
|
||||||
|
|
||||||
|
Requires `amount_sats` per the legacy path's existing contract.
|
||||||
|
"""
|
||||||
|
user, wallet = configured_user
|
||||||
|
|
||||||
|
await post_receivable(
|
||||||
|
client,
|
||||||
|
super_user_headers=super_user_headers,
|
||||||
|
user_id=user.id,
|
||||||
|
amount="50.00", currency="EUR",
|
||||||
|
description="Receivable for explicit-link test",
|
||||||
|
revenue_account=standard_accounts["revenue_rent"]["name"],
|
||||||
|
)
|
||||||
|
await list_user_entries(client, wallet_inkey=wallet.inkey)
|
||||||
|
|
||||||
|
# Caller passes explicit (but possibly empty) link list → legacy path.
|
||||||
|
r = await client.post(
|
||||||
|
"/libra/api/v1/receivables/settle",
|
||||||
|
headers=super_user_headers,
|
||||||
|
json={
|
||||||
|
"user_id": user.id,
|
||||||
|
"amount": "50.00",
|
||||||
|
"currency": "EUR",
|
||||||
|
"amount_sats": 55_000,
|
||||||
|
"payment_method": "cash",
|
||||||
|
"description": "Explicit-link settle",
|
||||||
|
"settled_entry_links": [], # opts out of auto-detect
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, f"legacy explicit-link path: {r.status_code} {r.text}"
|
||||||
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)
|
# DR Cash/Bank (asset increased), CR Accounts Receivable (asset decreased)
|
||||||
# This records that user paid their debt
|
# This records that user paid their debt
|
||||||
from .fava_client import get_fava_client
|
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
|
from decimal import Decimal
|
||||||
|
|
||||||
fava = get_fava_client()
|
fava = get_fava_client()
|
||||||
|
|
@ -2002,9 +2006,106 @@ async def api_settle_receivable(
|
||||||
"cash", "bank_transfer", "check", "other"
|
"cash", "bank_transfer", "check", "other"
|
||||||
]
|
]
|
||||||
|
|
||||||
if is_fiat_payment:
|
if is_fiat_payment and data.settled_entry_links is None:
|
||||||
# Fiat currency payment (cash, bank transfer, etc.)
|
# Auto-detect netting + credit-overflow path (libra-#33 + libra-#41).
|
||||||
# Record in fiat currency with sats as metadata
|
# 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:
|
if not data.amount_sats:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue