diff --git a/beancount_format.py b/beancount_format.py index a1bf874..486ad57 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -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, diff --git a/tests/test_settlement_api.py b/tests/test_settlement_api.py new file mode 100644 index 0000000..442a01e --- /dev/null +++ b/tests/test_settlement_api.py @@ -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}" diff --git a/views_api.py b/views_api.py index 032c15d..fb46809 100644 --- a/views_api.py +++ b/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,