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>
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""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 decimal import Decimal
|
|
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}"
|
|
# Amounts are exact Decimal strings (not floats) so the operator can
|
|
# act on them without precision loss.
|
|
assert Decimal(payload.get("cash_paid")) == Decimal("30.00")
|
|
assert Decimal(payload.get("net_obligation")) == Decimal("100.00")
|
|
assert Decimal(payload.get("receivable_total")) == Decimal("100.00")
|
|
assert Decimal(payload.get("payable_total")) == Decimal("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}"
|