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>
452 lines
17 KiB
Python
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}"
|
|
)
|