libra/tests/test_balances_api.py
Padreug 7a4b3022c2 Add integration test suite
113 passing tests + 3 skipped + 8 xfailed across 10 files, covering user
expense and income flow, admin receivable/revenue, settings + auth gates,
void/reject, manual payment requests, balance display, Lightning auth
paths, reconciliation API, and pure-function units. Runs against a real
Fava subprocess and full LNbits app via asgi_lifespan; the harness
captures the auth-flow / settings / env-var disciplines surfaced during
build-out (see tests/README.md and tests/conftest.py docstring).

Eight xfailed/skipped tests carry full implementations gated behind issues
#38, #39, #40 — they flip back on automatically when those land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00

452 lines
17 KiB
Python

"""Balance display tests — the user-named "mixture of income and expenses
displayed correctly" scenario.
The balance API returns figures from libra's perspective:
- Negative `fiat_balances[CCY]` → libra owes the user
- Positive `fiat_balances[CCY]` → user owes libra
- Sum across Payable + Receivable + Credit per currency
(Credit added per libra-#41: overpayment lands as a liability that
libra owes the user going forward, naturally subtracting from net.)
Lifetime totals (`total_expenses_fiat`, `total_income_fiat`) are kept
separate per the `models.py:93` comment — "original entries only; not net of
reconciliation" — so they don't reflect settlement activity or credit.
Excluded from the balance query: pending entries (flag `!`), voided entries
(tag `voided`). Tested explicitly here so the contract is locked in.
Note: this file does NOT cover post-settlement netting; that's blocked on
issue #33 (settlement leaves both per-user accounts non-zero) and lives in
the settlement test file.
"""
import importlib
from datetime import date
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
get_all_balances,
get_balance,
list_user_entries,
post_expense,
post_income,
post_receivable,
reject_entry,
)
def _libra_module(submodule: str):
"""Import a libra submodule via whichever path the harness uses (matches
the resolver in conftest.py)."""
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 then force a fresh Fava read.
Workaround for libra issue #37 — BQL balance reads can lag add_entry
by a few ms. The user-journal endpoint forces a Fava reload.
"""
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry_id,
)
await list_user_entries(client, wallet_inkey=wallet.inkey)
# ---------------------------------------------------------------------------
# Single-direction balances
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_pure_expense_balance_is_negative(
client, super_user_headers, configured_user, standard_accounts,
):
"""User submits a single expense → libra owes them → balance < 0 EUR."""
_, wallet = configured_user
entry = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="40.00", currency="EUR",
description=f"Pure expense {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, entry["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-40.0), (
f"expected -40 EUR (libra owes user), got {eur}"
)
@pytest.mark.anyio
async def test_pure_income_balance_is_positive(
client, super_user_headers, configured_user, standard_accounts,
):
"""User submits a single income → user owes libra → balance > 0 EUR.
`/entries/income` records that the user collected money on libra's
behalf, creating an `Assets:Receivable:User-{id}` debit until they
settle by handing the cash over.
"""
_, wallet = configured_user
entry = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="120.00", currency="EUR",
description=f"Pure income {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, entry["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(120.0), (
f"expected +120 EUR (user owes libra), got {eur}"
)
# ---------------------------------------------------------------------------
# Mixed direction — the headline scenario
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_mixed_expense_and_income_nets_correctly(
client, super_user_headers, configured_user, standard_accounts,
):
"""User has 50 EUR expense + 120 EUR income (both approved) → net
balance is +70 EUR (user owes libra 70).
This is the user's headline "displayed correctly" scenario — the
Payable and Receivable rows sum into one EUR figure.
"""
_, wallet = configured_user
expense = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="50.00", currency="EUR",
description=f"Coffee {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
income = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="120.00", currency="EUR",
description=f"Cash deposit {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
await _approve_and_refresh(client, wallet, super_user_headers, income["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(70.0), (
f"expected +70 EUR (120 - 50, user-owes-libra), got {eur} from {balance}"
)
@pytest.mark.anyio
async def test_mixed_expense_and_receivable_nets_correctly(
client, super_user_headers, configured_user, standard_accounts,
):
"""Admin-recorded receivable + user-submitted expense should net the
same way as expense + income — both push the receivable side."""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="80.00", currency="EUR",
description=f"Admin debt {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
expense = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="30.00", currency="EUR",
description=f"User expense {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(50.0), (
f"expected +50 EUR (80 - 30), got {eur} from {balance}"
)
# ---------------------------------------------------------------------------
# Lifetime totals (separate from net balance)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_lifetime_totals_track_originals_not_net(
client, super_user_headers, configured_user, standard_accounts,
):
"""`total_expenses_fiat` and `total_income_fiat` track originally-entered
amounts, not net obligation — see the `models.py:93` invariant. Even
after partial-direction submissions, the totals should equal the gross.
"""
_, wallet = configured_user
expense = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="45.00", currency="EUR",
description=f"e1 {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
income = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="80.00", currency="EUR",
description=f"i1 {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, expense["id"])
await _approve_and_refresh(client, wallet, super_user_headers, income["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
exp_eur = balance.get("total_expenses_fiat", {}).get("EUR", 0)
inc_eur = balance.get("total_income_fiat", {}).get("EUR", 0)
assert float(exp_eur) == pytest.approx(45.0), (
f"total_expenses_fiat should be gross 45, got {exp_eur}"
)
assert float(inc_eur) == pytest.approx(80.0), (
f"total_income_fiat should be gross 80, got {inc_eur}"
)
# ---------------------------------------------------------------------------
# Exclusions — pending and voided
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_pending_entries_excluded_from_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""Two expenses submitted, only one approved → only the approved one
moves the balance."""
_, wallet = configured_user
approved = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="25.00", currency="EUR",
description=f"approved-only {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
# Submit a second expense but leave it pending.
await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="1000.00", currency="EUR",
description=f"pending-not-counted {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_supplies"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, approved["id"])
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-25.0), (
f"only approved expense should count; pending 1000 must be excluded. "
f"got {eur}"
)
@pytest.mark.anyio
async def test_voided_entries_excluded_from_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""A voided entry stops contributing to the balance the moment it's
rejected — verified by submitting then rejecting and confirming the
balance is what it would be without that entry."""
_, wallet = configured_user
keep = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="35.00", currency="EUR",
description=f"keep {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
rejected = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="500.00", currency="EUR",
description=f"will-be-voided {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet, super_user_headers, keep["id"])
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=rejected["id"],
)
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert float(eur) == pytest.approx(-35.0), (
f"voided 500 must not contribute; only the 35 EUR keeper. got {eur}"
)
# ---------------------------------------------------------------------------
# Admin /balances/all
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_balances_all_includes_users_with_obligations(
client, super_user_headers, configured_user, configured_user_b,
standard_accounts,
):
"""`/balances/all` returns one row per user that has any Payable or
Receivable activity. Two users → two rows after both submit + approve.
"""
user_a, wallet_a = configured_user
user_b, wallet_b = configured_user_b
a_entry = await post_expense(
client,
wallet_inkey=wallet_a.inkey,
user_wallet_id=wallet_a.id,
amount="60.00", currency="EUR",
description=f"A-bal {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
b_entry = await post_expense(
client,
wallet_inkey=wallet_b.inkey,
user_wallet_id=wallet_b.id,
amount="90.00", currency="EUR",
description=f"B-bal {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await _approve_and_refresh(client, wallet_a, super_user_headers, a_entry["id"])
await _approve_and_refresh(client, wallet_b, super_user_headers, b_entry["id"])
rows = await get_all_balances(client, super_user_headers=super_user_headers)
by_id = {r.get("user_id")[:8]: r for r in rows if r.get("user_id")}
assert user_a.id[:8] in by_id, f"user A missing from /balances/all"
assert user_b.id[:8] in by_id, f"user B missing from /balances/all"
a_eur = by_id[user_a.id[:8]].get("fiat_balances", {}).get("EUR")
b_eur = by_id[user_b.id[:8]].get("fiat_balances", {}).get("EUR")
assert float(a_eur) == pytest.approx(-60.0), (
f"user A EUR balance wrong in /balances/all: {a_eur}"
)
assert float(b_eur) == pytest.approx(-90.0), (
f"user B EUR balance wrong in /balances/all: {b_eur}"
)
@pytest.mark.anyio
async def test_non_super_user_cannot_get_all_balances(
client, configured_user,
):
"""`/balances/all` is admin-only — regular user wallet admin-key 403s."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/balances/all",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
# ---------------------------------------------------------------------------
# Credit balance — libra-#41
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_credit_balance_subtracts_from_net(
client, configured_user,
):
"""A user-credit balance on `Liabilities:Credit:User-X` flows into the
displayed net so the user-facing balance is always honest about what
libra owes them.
`#41` will land the settlement-side overflow logic that writes credit
automatically. This test pre-creates the credit account and posts a
balanced credit-bearing transaction directly via Fava so we can lock
in the BQL-side behaviour (`get_user_balance_bql` includes the Credit
namespace alongside Payable + Receivable) ahead of the settlement
endpoint changes in #14.
"""
user, wallet = configured_user
fava_client_mod = _libra_module("fava_client")
fava = fava_client_mod.get_fava_client()
# Open the per-user credit account in Beancount. The settlement endpoint
# will do this via `get_or_create_user_account` when #14 lands.
credit_account = f"Liabilities:Credit:User-{user.id[:8]}"
await fava.add_account(credit_account, currencies=["EUR", "SATS"])
# Manually post a balanced entry mimicking what the future settlement
# overflow leg looks like in isolation:
# DR Assets:Cash +30 EUR (libra receives cash)
# CR Liabilities:Credit -30 EUR (libra owes user that 30 going forward)
tag = uuid4().hex[:6]
beancount_format = _libra_module("beancount_format")
entry = beancount_format.format_transaction(
date_val=date.today(),
flag="*",
narration=f"Credit-balance test {tag}",
postings=[
{"account": "Assets:Cash", "amount": "30.00 EUR"},
{"account": credit_account, "amount": "-30.00 EUR"},
],
tags=["credit-test"],
links=[f"credit-test-{tag}"],
meta={"user-id": user.id, "source": "test"},
)
await fava.add_entry(entry)
# Force a fresh Fava read before the BQL balance query (libra-#37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
# The user's EUR balance should now read -30 (libra owes user 30 via
# credit). Without the BQL change, this would read 0 because the query
# would skip the Credit namespace entirely.
balance = await get_balance(client, wallet_inkey=wallet.inkey)
eur = balance.get("fiat_balances", {}).get("EUR")
assert eur is not None, f"missing EUR in fiat_balances: {balance}"
assert float(eur) == pytest.approx(-30.0), (
f"expected -30 EUR (libra owes user via credit), got {eur} from {balance}"
)
# The accounts breakdown should surface the credit row so UIs can render
# it as a distinct line item per #41's display contract. `accounts` (the
# legacy field on UserBalance) stays empty for back-compat; the new
# `account_balances` field carries the BQL per-account breakdown.
account_balances = balance.get("account_balances", [])
credit_rows = [
a for a in account_balances if "Credit" in (a.get("account") or "")
]
assert credit_rows, (
f"credit account missing from breakdown — UI can't render 'You have "
f"30 EUR credit' line item. account_balances: {account_balances}"
)