Compare commits

...

30 commits

Author SHA1 Message Date
4b01428a17 Merge pull request 'Wire admin add-account endpoint into the UI' (#46) from feat/add-account-ui into main
Reviewed-on: #46
2026-06-18 10:03:10 +00:00
3adb3d356a fix(accounts): match Beancount's DATE grammar in duplicate detection (libra-#48)
_open_directive_exists hardcoded '^YYYY-MM-DD open ' (dash-only, 2-digit,
single-space), but Beancount's DATE token (parser/lexer.l) is
(17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+ and inter-token whitespace is any
[ \t\r] run. So a validly-formatted existing Open written as '2024/3/5 open X'
or '2020-01-01  open  X' escaped detection → duplicate Open appended →
bean-check rejects the file. Anchor on Beancount's actual date pattern and
[ \t]+ separators. Adds parametrized coverage for slash/single-digit/multi-
space/tab variants.

Found in a coherence pass over the Beancount source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:27:18 +02:00
39440b75a7 fix(accounts): recover ledger-only account instead of blanket 409 (libra-#50)
When add_account reported the Open already existed, the endpoint raised
409 before the DB-mirror step — so an account present in the ledger but
missing from libra's DB (a prior sync failure with no cross-DB atomicity,
or an out-of-band open) was stranded: invisible to permissions with no
recovery path. Now 409 only when the account is already in the DB too;
otherwise sync it and return success. Adds a recovery test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:13:43 +02:00
26eb9d4579 fix(accounts): don't currency-constrain per-user account opens (libra-#49)
get_or_create_user_account opened per-user receivable/payable accounts
constrained to EUR/SATS/USD, so a posting in any other currency tripped
'Invalid currency CAD/GBP/JPY for account Assets:Receivable:User-…' at
bean-check — the exact errors the optional-currencies work set out to fix,
which had only reached the admin chart-account path. Open user accounts
unconstrained (currencies=None) so they hold arbitrary fiat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:06:57 +02:00
0ea96cd384 fix(accounts): anchor duplicate-account detection to a real Open directive (libra-#48)
The existence check matched 'open <name>' anywhere in the chart source,
so a prior account's description metadata or a comment mentioning the
name produced a false 409, while a real directive with an inline comment
and no space ('open X;legacy') was missed → a duplicate Open was appended
and bean-check then rejected the file, breaking every later /api/source
write. Extract the check into a pure _open_directive_exists() anchored to
'^YYYY-MM-DD open <name>' with an account-boundary negative-lookahead, and
unit-test both failure directions plus prefix/child non-matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:06:28 +02:00
89f0f8ac3a test(accounts): cover admin add-account endpoint
10 integration tests for POST /api/v1/admin/accounts: unconstrained Open
write + escaped description metadata, explicit-currency path, duplicate->409,
invalid-prefix->400, invalid-characters->400 (parametrized), super-user-only
->403. Adds the add_chart_account helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00
87a45ee4d5 test(harness): split-layout ledger + disable rate limiter
The test harness was never updated to the post-server-deploy#4 split ledger
layout, so libra's per-user account opens (routed to accounts/users.beancount
by fava_client._infer_target_file) 500'd as a 'non-source file' and fell back
to DB-only — breaking the balance test and contributing to settlement errors.
Make the harness ledger a faithful split (root includes accounts/chart.beancount
+ accounts/users.beancount; title stays in root so the slug still matches).

Also raise lnbits_rate_limit_no for the session: the full suite fires >200
req/min and the default limiter 429'd fixture setup intermittently (10-11
errors). The limiter is built once at app creation, so setting it in the
session settings fixture (before the app fixture) disables it suite-wide.

Net: full suite goes from 1 failed / ~10 errors to fully green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:25:27 +02:00
cd5a6edb7d feat(accounts): validate account-name characters server-side
The endpoint only checked the root prefix, so a direct API call (bypassing
the UI) could write a malformed Open directive into the ledger source.
Add _validate_account_name mirroring Beancount's core/account.py grammar
(root [\p{Lu}][\p{L}\p{Nd}-]*, sub [\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*, >=1
sub-account) — verified to match beancount.core.account.is_valid across
20 cases incl. Unicode, digit-start subs, hyphens. Align the client
segment regex to the same rule (was ASCII-only, rejected valid names).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:55:45 +02:00
051c9f0c22 feat(ui): constrain add-account to a root-type dropdown + sub-path
Free-typing the full hierarchical name let admins fat-finger the parent
(wrong/invalid root). Replace the single name field with a required
Account Type select (the 5 valid roots, mirroring _VALID_ACCOUNT_PREFIXES)
plus a sub-account input, a live 'Will create: ...' preview, and
per-segment validation (each part must be a capitalized Beancount
account component). The root prefix is now structurally guaranteed valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:15:06 +02:00
caef3cf5e8 fix(accounts): 409 when admin-adding an account that already exists
add_account no-ops if the Open directive is already present but returned
a normal-looking dict, so the admin endpoint reported success ('created
(sync pending)') for a duplicate. Return an already_existed flag and
raise 409 from the endpoint. Also anchor the existence check on the Open
directive with a trailing-boundary match so a prefix (Expenses:Gas)
doesn't match a longer sibling (Expenses:GasStation). The flag is
additive, so the idempotent user-account path keeps no-opping silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:07:39 +02:00
7456574f65 fix(accounts): default CreateChartAccount.currencies to None
The UI omits currencies so the Open directive is written unconstrained,
but the model defaulted currencies to ["EUR","SATS","USD"], so Pydantic
refilled them and the endpoint passed the constraint through — every
admin-created account got a currency-constrained Open (which would
reject postings in other currencies, the same CAD/GBP/JPY bean-check
class we hit on user accounts). Default to None so omission reaches
add_account and the directive is unconstrained; an explicit list still
works for API callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:53:04 +02:00
9dd46e818c feat(ui): wire admin add-account endpoint into Chart of Accounts
Surface the existing POST /api/v1/admin/accounts endpoint in the UI: a
super-user-only 'Add Account' button on the Chart of Accounts card opens
a dialog for the hierarchical account name + optional description, posts
with the wallet admin key (require_super_user), then reloads accounts.
Client-side prefix validation mirrors the server's _VALID_ACCOUNT_PREFIXES.
No currency input — an Open directive does not require currency constraints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:31:42 +02:00
788a9998f6 fix(fava): escape string metadata + make Open currencies optional
add_account wrote free-text metadata values straight into the ledger
source via /api/source with no escaping — an unescaped quote or newline
in an admin-supplied description would corrupt the Beancount file (or
forge extra metadata lines). Escape backslash/quote/newline per the
tokenizer's cunescape rules (verified round-trip through beancount's
parser). Also make the currency constraint list optional so an Open
directive can be written unconstrained (currencies are an optional
part of the directive, not required).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:31:27 +02:00
16ae6c2000 docs(tests): record known-good lnbits/dev invocation + env gotchas
The suite targets the lnbits dev worktree (needs lnbits.core.signers)
and trips on three non-obvious environment requirements, each of which
cost a failed run today: LNBITS_EXTENSIONS_PATH is the parent of an
extensions/ dir, the data folder must be a fresh temp dir per run, and
lnbits dev mandates LNBITS_KEY_MASTER at boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:39:14 +02:00
15d9910073 Resolve entry identity via entry-id metadata; unfuse user references (libra-#42)
Approving a pending entry created with a reference (e.g. invoice
"42-144") 404'd with "Pending entry unknown not found": the list
endpoints recovered the entry id by parsing links for a libra- prefix,
but reference-bearing entries displace that link with the fused
"{reference}-{entry_id}" form, so the id surfaced as the literal
"unknown" and the approve call round-tripped it.

Make the entry-id transaction metadata the single canonical identity:

- _extract_entry_id() resolves metadata-first (libra- link parsing kept
  only for pre-dfdcc44 ledger history); used by /entries/user,
  /entries/pending, approve, and reject.
- Creation endpoints no longer fuse the reference with the entry id —
  the user reference becomes its own sanitized link and round-trips
  verbatim in API responses. Typed exp-/rcv-/inc- links stay as the
  settlement-tracking handles.
- format_revenue_entry now writes entry-id metadata like its siblings
  and sanitizes its reference link (was appended raw); generic
  POST /entries sanitizes its reference link too.
- User-journal reference extraction skips all system link prefixes
  (typed links used to leak into the reference field).

Contract documented in CLAUDE.md (Data Integrity → Entry Identity &
Links), pinned by tests/test_entry_identity_api.py and formatter
contract tests in test_unit.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:39:06 +02:00
116df46d38 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>
2026-06-07 15:39:45 +02:00
50658440a4 Surface user credit balance in GET /balance per libra-#41
Extends get_user_balance_bql and get_all_user_balances_bql to fold
Liabilities:Credit:User-X into the same query as Payable and Receivable.
Credit is the overpay-absorbing liability that libra owes the user going
forward — it carries the same sign as Payable, so the existing fiat
aggregation subtracts it from net obligation without further changes.

Adds UserBalance.account_balances to surface the BQL per-account
breakdown so libra extension UI and webapp can render Payable /
Receivable / Credit as distinct line items. The legacy `accounts` field
stays empty for back-compat with anything reading the older shape.

Prepares for libra-#33 / libra-#41: settlement netting (#14 task) will
write the overflow leg to credit; this changeset makes sure that, the
moment credit exists, the displayed net everywhere already reflects it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:39:45 +02:00
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
9c88993c13 Merge pull request 'Show voided transactions in user-entries endpoint' (#34) from feat/show-voided-user-entries into main
Reviewed-on: #34
2026-06-07 13:38:30 +00:00
781059af5f fix(dashboard): detect voided rows by tag, not by flag char
The admin transactions table assumed voided entries used flag='x', but
the libra reject convention keeps the '!' flag and appends a 'voided'
tag. Without this, the dashboard rendered voided rows as orange 'Pending'
once they started reaching it. Detect via tag and give the voided icon
precedence over the flag-based branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:47:42 +02:00
1c89e69030 feat(api): include voided transactions in user-entries endpoint
The /api/v1/entries/user view was silently dropping any transaction tagged
'voided', so users couldn't see entries that had been rejected against
their accounts. Per the libra reject convention, voided entries keep the
'!' flag and carry a 'voided' tag for audit; clients can use the tag to
style them distinctly. Pending-approval listing still filters voided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:47:27 +02:00
68cdc4f9ee Merge pull request 'Route account writes for the split Fava ledger layout' (#32) from feat/split-ledger into main
Reviewed-on: #32
2026-06-06 18:03:25 +00:00
9e7795b541 add_account: always append at end of file
The original "find last Open directive, insert after its metadata" logic
was a clever optimisation for the monolithic ledger where opens, txns,
and assertions all lived in one file -- you wanted new opens grouped with
existing opens, not appended after a long transaction tail.

Post-split, each include file has one mutation profile:
  - accounts/chart.beancount: only Open directives
  - accounts/users.beancount: only Open directives
  - transactions.beancount:   only Transactions

There is no longer a content shape that benefits from mid-file insertion;
the existing heuristic also had a pre-existing bug where it only matched
'open ' OR '{current_year}-' as line prefixes, so 1970-* seed opens were
invisible and the search "stuck" to the first current-year line in the
file (which on aio-demo ended up being the wrong place).

Drop the search; always append. Simpler, chronological, append-only
friendly.

Refs: aiolabs/libra#28
2026-06-06 19:39:55 +02:00
09a5d6ed55 Polish account-creation flow: insertion point, user_id consistency, startup race
Three small fixes shaken out by live testing on aio-demo:

1. fava_client.add_account: when the target file has no Open directives
   yet (e.g. the empty accounts/users.beancount seed), append at end of
   file instead of inserting at index 0. Keeps the seed header comments
   at the top where they belong.

2. account_sync.sync_single_account_from_beancount: read the full user_id
   from Beancount metadata when present, fall back to the name-derived
   8-char prefix otherwise. crud.get_or_create_user_account writes the
   full 32-char user_id into Beancount metadata when creating per-user
   accounts; the sync function was only looking at the account name and
   returning the prefix, so the post-sync `WHERE user_id=:user_id` query
   in crud.py missed the row and fell through the UNIQUE-constraint
   recovery path. Three lines of warning noise per user-account creation.

3. tasks.wait_for_account_sync: await `wait_for_fava_client()` (new
   helper backed by an asyncio.Event in fava_client.py) before the first
   sync iteration. Previously the sync task started in libra_start()
   raced the fire-and-forget `_init_fava()` coroutine and reliably
   crashed the first run with "Fava client not initialized".

Refs: aiolabs/libra#28
2026-06-06 19:36:39 +02:00
d82443d040 fava_client: resolve relative target_file paths against the ledger root
Fava's /api/source endpoint rejects relative paths with HTTP 500
(NonSourceFileError: "Trying to read a non-source file at '...'"). The
include-aware `_infer_target_file` helper returns relative paths
(e.g. "accounts/users.beancount"), so add a `_resolve_target_file`
hook that prepends the ledger root directory.

The dirname is derived from a one-time GET /api/options and cached on
the FavaClient instance (which is a module-level singleton), guarded by
an asyncio.Lock so concurrent first-callers don't double-fetch.

Absolute paths pass through unchanged, so the admin endpoint that
explicitly passes target_file="accounts/chart.beancount" works the same
as one that passes "/var/lib/fava/accounts/chart.beancount".

Verified against aio-demo's live fava: relative paths now produce
HTTP 200 reads on options.beancount, accounts/chart.beancount,
accounts/users.beancount, and transactions.beancount.

Refs: aiolabs/libra#28
2026-06-06 19:16:50 +02:00
34ecb3f249 Add POST /api/v1/admin/accounts for chart-of-accounts entries
Companion to the fava ledger split (aiolabs/server-deploy#4). Super-user
endpoint that adds a new Open directive to accounts/chart.beancount via
fava_client.add_account (explicit target_file), then mirrors the account
into Libra's DB via sync_single_account_from_beancount so permissions can
be granted on it.

Validates the account name against the five Beancount top-level prefixes
(Assets:/Liabilities:/Equity:/Income:/Expenses:) and returns 400 on a bad
prefix.

Per-user accounts (matching :User-xxxxxxxx) keep their existing code path
via crud.get_or_create_user_account, which inherits the inferred target_file
(accounts/users.beancount) from the add_account default.

Backend only -- the LNbits admin UI on top is tracked separately as
aiolabs/libra#30.

Refs: aiolabs/libra#29
2026-06-06 15:30:23 +02:00
894de72953 Route fava_client.add_account writes per account type
The Fava-backed ledger is being split into purpose-specific files (see
aiolabs/server-deploy#4): accounts/chart.beancount for static + admin-managed
opens, accounts/users.beancount for libra-appended per-user opens.

Add a `target_file` parameter to `add_account` that defaults to inference
from the account name (`:User-[0-9a-f]{8}$` -> users.beancount, otherwise
chart.beancount). Drop the now-redundant `GET /api/options` call that was
only used to discover the root file path. Callers that need explicit
control (e.g. the upcoming admin chart-edit endpoint) can pass
`target_file=` directly.

The retry loop, write lock, and insertion-point search are unchanged --
each included file is a self-contained source the existing logic operates
on cleanly.

Refs: aiolabs/libra#28
2026-06-06 15:27:28 +02:00
f0899bf788 Update CLAUDE.md to reflect Fava-as-sole-source-of-truth for journal entries
The previous journal_entries/entry_lines local mirror was removed during
the Fava migration but the docs still described it as a local cache.
Replace with explicit statement that Fava is canonical and the remaining
SQLite tables hold orthogonal operational state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 14:48:57 +02:00
1201557f0c Gate cross-user admin endpoints behind require_super_user
Twenty-six endpoints documented "(admin only)" were using
require_admin_key, which only checks the caller owns a wallet with
its admin key — not LNbits-instance admin. Any logged-in user could
fabricate receivables against any other user_id, grant themselves
MANAGE permission on any account, create + self-assign privileged
roles, etc.

Swaps Depends(require_admin_key) -> Depends(require_super_user) on:
receivable/revenue creation, equity-eligibility grant/revoke/list,
permission grant/list/revoke/bulk/bulk-grant, account-sync admin,
role + role-permission + user-role CRUD, cross-user contributions
and unsettled-entries reports.

Also deletes the unsafe duplicate /api/v1/pay-user — both function
defs shared the name api_pay_user, the second shadowed the first at
module scope but FastAPI registered both routes. /api/v1/payables/pay
already provides the super-user-gated equivalent.

Two pre-existing orphan wallet.wallet.user references inside
api_settle_receivable and api_approve_manual_payment_request (both
already used the auth parameter) would have raised NameError at
runtime; fixed in passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 22:57:59 +02:00
0a7c39adcb Show Outstanding Balances split per direction per currency
When a user has entries in multiple currencies that go in opposite
directions — e.g. an income entry in EUR (user owes the org) and an
expense entry in CAD (org owes user) — the previous row collapsed
both into a single "Owes you" / "You owe" label driven by the net
sats balance. The fiat amounts were displayed via Math.abs(), hiding
the per-currency signs the backend already returns, so the row was
actively misleading: it showed €200 and CA$300 under one direction
when in reality they point in opposite directions.

Render up to two grouped lines instead — "Owes you €200.00" and
"You owe CA$300.00" — using new owesYouFiat / youOweFiat helpers
that filter the signed fiat_balances dict by sign. Net sats stays
as a small caption with an explicit "(receivable)"/"(payable)"
qualifier, since sats can be netted but distinct fiat currencies
can't without a spot rate. Falls back to the old single-line render
when there are no fiat balances (sats-only entries).
2026-05-17 20:12:51 +02:00
27 changed files with 5566 additions and 263 deletions

View file

@ -49,13 +49,9 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective
### Database Schema
**Note**: With Fava integration, Libra maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava.
**Fava is the sole source of truth for journal entries.** Libra does NOT maintain a local mirror of transactions — the previous `journal_entries` and `entry_lines` tables were removed during the Fava migration. All transaction reads (history, balances, summaries) go through Fava's HTTP API; writes go through `PUT /api/add_entries` and are serialized via `FavaClient._write_lock`. When retrieving journal entries from Fava for UI display, results are enriched with a `username` field from LNbits user data.
**journal_entries**: Transaction headers stored locally and synced to Fava
- `flag` field: `*` (cleared), `!` (pending), `#` (flagged), `x` (void)
- `meta` field: JSON storing source, tags, audit info
- `reference` field: Links to payment_hash, invoice numbers, etc.
- Enriched with `username` field when retrieved via API (added from LNbits user data)
The SQLite tables below hold **operational state** that Fava doesn't (and shouldn't) own — workflow, RBAC, settings, reconciliation assertions. None of it is derivable from the Beancount file; they are independent stores, not caches.
**extension_settings**: Libra wallet configuration (admin-only)
- `libra_wallet_id` - The LNbits wallet used for Libra operations
@ -213,7 +209,8 @@ entry = format_transaction(
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
],
tags=["groceries"],
links=["libra-entry-123"]
links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata
meta={"entry-id": "a1b2c3d4e5f60708"}
)
# Submit to Fava
@ -221,6 +218,8 @@ client = get_fava_client()
result = await client.add_entry(entry)
```
Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links).
**Querying Balances**:
```python
# Query user balance from Fava
@ -282,7 +281,8 @@ entry = format_transaction(
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
],
tags=["utilities"],
links=["libra-tx-123"]
links=["exp-0123456789abcdef"],
meta={"entry-id": "0123456789abcdef"}
)
client = get_fava_client()
@ -310,6 +310,13 @@ result = await client.query(query)
3. User accounts use `user_id` (NOT `wallet_id`) for consistency
4. All accounting calculations delegated to Beancount/Fava
**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on):
- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id.
- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source.
- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics).
- `ln-{payment_hash[:16]}` links mark Lightning payments.
- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code.
**Validation** is performed in `core/validation.py`:
- Pure validation functions for entry correctness before submitting to Fava

View file

@ -320,11 +320,20 @@ async def sync_single_account_from_beancount(account_name: str) -> bool:
# Create in Libra DB
account_type = infer_account_type_from_name(account_name)
user_id = extract_user_id_from_account_name(account_name)
# Prefer the full user_id stored in Beancount metadata (libra writes it
# when crud.get_or_create_user_account calls fava.add_account). Fall
# back to the name-derived 8-char prefix for accounts imported without
# metadata. This keeps user_id consistent with what the caller will
# query for, avoiding a churn cycle through the UNIQUE-constraint
# recovery path in crud.py.
description = None
meta_user_id = None
if "meta" in bc_account and isinstance(bc_account["meta"], dict):
description = bc_account["meta"].get("description")
meta_user_id = bc_account["meta"].get("user_id")
user_id = meta_user_id or extract_user_id_from_account_name(account_name)
await create_account(
CreateAccount(

View file

@ -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,
@ -812,7 +945,8 @@ def format_revenue_entry(
entry_date: date,
fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None
reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Format a revenue entry (libra receives payment directly).
@ -829,7 +963,8 @@ def format_revenue_entry(
entry_date: Date of payment
fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned)
reference: Optional reference
reference: Optional reference (invoice ID, etc.) stored as its own link
entry_id: Optional unique entry ID (generated if not provided)
Returns:
Fava API entry dict
@ -845,6 +980,9 @@ def format_revenue_entry(
fiat_amount=Decimal("50.00")
)
"""
if not entry_id:
entry_id = generate_entry_id()
amount_sats_abs = abs(amount_sats)
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None
@ -869,12 +1007,13 @@ def format_revenue_entry(
# Note: created-via is redundant with #revenue-entry tag
entry_meta = {
"source": "libra-api"
"source": "libra-api",
"entry-id": entry_id
}
links = []
if reference:
links.append(reference)
links.append(sanitize_link(reference))
return format_transaction(
date_val=entry_date,

View file

@ -250,9 +250,13 @@ async def get_or_create_user_account(
if not fava_account_exists:
# Create account in Fava/Beancount via Open directive
logger.info(f"[FAVA CREATE] Creating account in Fava: {account_name}")
# Unconstrained Open: a per-user receivable/payable legitimately
# holds arbitrary fiat (CAD/GBP/JPY/…). Constraining it to
# EUR/SATS/USD made any posting in another currency fail
# bean-check (the errors this account path originally exhibited).
await fava.add_account(
account_name=account_name,
currencies=["EUR", "SATS", "USD"], # Support common currencies
currencies=None,
metadata={
"user_id": user_id,
"description": f"User-specific {account_type.value} account"

View file

@ -18,6 +18,7 @@ See: https://github.com/beancount/fava/blob/main/src/fava/json_api.py
"""
import asyncio
import re
import httpx
from typing import Any, Dict, List, Optional
from decimal import Decimal
@ -30,6 +31,68 @@ class ChecksumConflictError(Exception):
pass
# Per-user account names end with :User-{user_id[:8]} (8 hex chars). Anything
# matching is routed to accounts/users.beancount; anything else goes to
# accounts/chart.beancount. See `_infer_target_file` and `add_account`.
_USER_ACCT_RE = re.compile(r":User-[0-9a-f]{8}$")
def _infer_target_file(account_name: str) -> str:
"""Pick the Beancount include file for an Open directive based on account name."""
if _USER_ACCT_RE.search(account_name):
return "accounts/users.beancount"
return "accounts/chart.beancount"
def _escape_beancount_string(value: str) -> str:
"""Escape a value for safe inclusion in a Beancount string literal.
Beancount's tokenizer unescapes \\", \\n, \\t, \\r, \\\\ etc. (tokens.c
cunescape). Unescaped quotes or newlines in free-text metadata written
straight into the ledger source would corrupt the file, so escape the
backslash first (to keep it round-tripping) then quotes and newlines.
"""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
# Beancount's DATE token (parser/lexer.l): (17|18|19|20)[0-9]{2}[-/][0-9]+[-/][0-9]+
# — '-' OR '/' separators, 1+ digit month/day. Inter-token whitespace is any
# run of [ \t\r] (ignored by the lexer). The duplicate-detection regex must
# mirror this, or a validly-formatted existing Open (e.g. '2024/3/5 open X' or
# '2020-01-01 open X') escapes detection and a duplicate Open is appended,
# which bean-check then rejects — breaking every later write.
_OPEN_DATE = r"(?:17|18|19|20)\d\d[-/]\d+[-/]\d+"
def _open_directive_exists(source: str, account_name: str) -> bool:
"""Return True if `source` already contains an Open directive for exactly
`account_name`.
Anchored to a real `<date> open <account>` directive line (re.MULTILINE),
with `<date>` and the inter-token whitespace matching Beancount's grammar,
so the account name can't match text inside another account's description
metadata or a comment (false positive spurious 409). The trailing
negative-lookahead `(?![\\w:-])` requires the next char not to be an
account-continuation char, so:
- a prefix (Expenses:Gas) does not match a longer sibling
(Expenses:GasStation / Expenses:Gas:Vehicle), and
- a real directive with an inline comment and no space
(`open Expenses:Gas;legacy`) is still detected (`;` ends the name).
"""
return bool(
re.search(
rf"^{_OPEN_DATE}[ \t]+open[ \t]+{re.escape(account_name)}(?![\w:-])",
source,
re.MULTILINE,
)
)
class FavaClient:
"""
Async client for Fava REST API.
@ -66,6 +129,46 @@ class FavaClient:
# Per-user locks for user-specific operations (reduces contention)
self._user_locks: Dict[str, asyncio.Lock] = {}
# Cached absolute dirname of the root ledger file, derived from
# GET /api/options on first need. Used by `_resolve_target_file` to
# turn relative include paths (e.g. "accounts/users.beancount") into
# the absolute paths fava's /api/source endpoint requires.
self._main_dir_cache: Optional[str] = None
self._main_dir_lock = asyncio.Lock()
async def _resolve_target_file(self, target_file: str) -> str:
"""
Turn a relative include path into the absolute path fava expects.
Fava's /api/source endpoint refuses relative paths with HTTP 500
(NonSourceFileError). Resolve any non-absolute target_file by
prepending the directory of the root ledger file (cached after
the first GET /api/options).
Args:
target_file: Relative (e.g. "accounts/users.beancount") or
absolute path.
Returns:
Absolute path under fava's ledger root.
"""
import os
if os.path.isabs(target_file):
return target_file
if self._main_dir_cache is None:
async with self._main_dir_lock:
if self._main_dir_cache is None:
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(f"{self.base_url}/options")
resp.raise_for_status()
main_file = resp.json()["data"]["beancount_options"]["filename"]
self._main_dir_cache = os.path.dirname(main_file)
logger.debug(f"Cached fava ledger root dir: {self._main_dir_cache}")
return os.path.join(self._main_dir_cache, target_file)
def get_user_lock(self, user_id: str) -> asyncio.Lock:
"""
Get or create a lock for a specific user.
@ -766,10 +869,15 @@ class FavaClient:
# GROUP BY currency prevents mixing EUR and SATS face values in sum(number).
# sum(weight) gives SATS for both EUR @@ SATS entries and plain SATS entries.
# sum(number) on EUR rows gives the fiat amount; on SATS rows gives sats paid.
# Credit is the overpay-absorbing liability per libra-#41 — it lives
# on the same per-user namespace as Payable and contributes to the
# user's net obligation with the same sign as Payable (negative on
# Liabilities means libra owes user). Folding it into the same query
# means the displayed net always already accounts for credit.
query = f"""
SELECT account, currency, sum(number), sum(weight)
WHERE account ~ ':User-{user_id_prefix}'
AND (account ~ 'Payable' OR account ~ 'Receivable')
AND (account ~ 'Payable' OR account ~ 'Receivable' OR account ~ 'Credit')
AND flag = '*'
GROUP BY account, currency
"""
@ -916,10 +1024,11 @@ class FavaClient:
"""
from decimal import Decimal
# GROUP BY currency prevents mixing EUR and SATS face values in sum(number)
# GROUP BY currency prevents mixing EUR and SATS face values in sum(number).
# Credit per libra-#41 — see get_user_balance_bql for the rationale.
query = """
SELECT account, currency, sum(number), sum(weight)
WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-')
WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-' OR account ~ 'Credit:User-')
AND flag = '*'
GROUP BY account, currency
"""
@ -1484,16 +1593,23 @@ class FavaClient:
async def add_account(
self,
account_name: str,
currencies: list[str],
currencies: Optional[list[str]] = None,
opening_date: Optional[date] = None,
metadata: Optional[Dict[str, Any]] = None,
target_file: Optional[str] = None,
max_retries: int = 3
) -> Dict[str, Any]:
"""
Add an account to the Beancount ledger via an Open directive.
NOTE: Fava's /api/add_entries endpoint does NOT support Open directives.
This method uses /api/source to directly edit the Beancount file.
This method uses /api/source to directly edit a Beancount file.
The ledger is split across multiple include files
(see modules/services/fava-seeds.nix in server-deploy). Per-user
opens go to accounts/users.beancount; admin/static chart opens go to
accounts/chart.beancount. If `target_file` is not passed, it is
inferred from the account name via `_infer_target_file`.
This method implements optimistic concurrency control with retry logic:
- Acquires a global write lock before modifying the ledger
@ -1506,6 +1622,8 @@ class FavaClient:
currencies: List of currencies for this account (e.g., ["EUR", "SATS"])
opening_date: Date to open the account (defaults to today)
metadata: Optional metadata for the account
target_file: Beancount file path (relative to ledger root) to append
the Open directive to. Defaults to inference from `account_name`.
max_retries: Maximum number of retry attempts on checksum conflict (default: 3)
Returns:
@ -1515,17 +1633,18 @@ class FavaClient:
ChecksumConflictError: If all retry attempts fail due to concurrent modifications
Example:
# Add a user's receivable account
# User-account names route to accounts/users.beancount automatically.
result = await fava.add_account(
account_name="Assets:Receivable:User-abc123",
account_name="Assets:Receivable:User-abc12345",
currencies=["EUR", "SATS", "USD"],
metadata={"user_id": "abc123", "description": "User receivables"}
metadata={"user_id": "abc12345", "description": "User receivables"}
)
# Add a user's payable account
# Static / admin-added chart entries route to accounts/chart.beancount.
result = await fava.add_account(
account_name="Liabilities:Payable:User-abc123",
currencies=["EUR", "SATS"]
account_name="Expenses:NewCategory",
currencies=["EUR"],
target_file="accounts/chart.beancount",
)
"""
from datetime import date as date_type
@ -1533,6 +1652,12 @@ class FavaClient:
if opening_date is None:
opening_date = date_type.today()
if target_file is None:
target_file = _infer_target_file(account_name)
# Fava's /api/source requires absolute paths; convert if needed.
target_file = await self._resolve_target_file(target_file)
last_error = None
for attempt in range(max_retries):
@ -1540,18 +1665,10 @@ class FavaClient:
async with self._write_lock:
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
# Step 1: Get the main Beancount file path from Fava
options_response = await client.get(f"{self.base_url}/options")
options_response.raise_for_status()
options_data = options_response.json()["data"]
file_path = options_data["beancount_options"]["filename"]
logger.debug(f"Fava main file: {file_path}")
# Step 2: Get current source file (fresh read on each attempt)
# Step 1: Get current source file (fresh read on each attempt)
response = await client.get(
f"{self.base_url}/source",
params={"filename": file_path}
params={"filename": target_file}
)
response.raise_for_status()
source_data = response.json()["data"]
@ -1559,47 +1676,56 @@ class FavaClient:
sha256sum = source_data["sha256sum"]
source = source_data["source"]
# Step 3: Check if account already exists (may have been created by concurrent request)
if f"open {account_name}" in source:
logger.info(f"Account {account_name} already exists in Beancount file")
return {"data": sha256sum, "mtime": source_data.get("mtime", "")}
# Step 2: Check if account already exists (may have been
# created by a concurrent request). See
# _open_directive_exists for the anchoring rationale.
if _open_directive_exists(source, account_name):
logger.info(f"Account {account_name} already exists in {target_file}")
return {
"data": sha256sum,
"mtime": source_data.get("mtime", ""),
"already_existed": True,
}
# Step 4: Find insertion point (after last Open directive AND its metadata)
# Step 3: Always append at end of file.
# Post-split layout, each include file has one mutation
# profile (only Open directives in chart/users, only
# Transactions in transactions.beancount), so there's no
# reason to slot new entries mid-file. Append-only also
# keeps the seed header comments at the top and makes
# the file's evolution trivially readable.
lines = source.split('\n')
insert_index = 0
for i, line in enumerate(lines):
if line.strip().startswith(('open ', f'{opening_date.year}-')) and 'open' in line:
# Found an Open directive, now skip over any metadata lines
insert_index = i + 1
# Skip metadata lines (lines starting with whitespace)
while insert_index < len(lines) and lines[insert_index].startswith((' ', '\t')) and lines[insert_index].strip():
insert_index += 1
insert_index = len(lines)
# Step 5: Format Open directive as Beancount text
currencies_str = ", ".join(currencies)
open_lines = [
"",
f"{opening_date.isoformat()} open {account_name} {currencies_str}"
]
# Step 4: Format Open directive as Beancount text.
# Currencies are an optional constraint on an Open
# directive; when none are given the account accepts
# any commodity.
open_directive = f"{opening_date.isoformat()} open {account_name}"
if currencies:
open_directive += f" {', '.join(currencies)}"
open_lines = ["", open_directive]
# Add metadata if provided
if metadata:
for key, value in metadata.items():
# Format metadata with proper indentation
if isinstance(value, str):
open_lines.append(f' {key}: "{value}"')
open_lines.append(
f' {key}: "{_escape_beancount_string(value)}"'
)
else:
open_lines.append(f' {key}: {value}')
# Step 6: Insert into source
# Step 5: Insert into source
for i, line in enumerate(open_lines):
lines.insert(insert_index + i, line)
new_source = '\n'.join(lines)
# Step 7: Update source file via PUT /api/source
# Step 6: Update source file via PUT /api/source
update_payload = {
"file_path": file_path,
"file_path": target_file,
"source": new_source,
"sha256sum": sha256sum
}
@ -1612,8 +1738,8 @@ class FavaClient:
response.raise_for_status()
result = response.json()
logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}")
return result
logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}")
return {**result, "already_existed": False}
except httpx.HTTPStatusError as e:
# Check for checksum conflict (HTTP 412 Precondition Failed or similar)
@ -1927,6 +2053,10 @@ class FavaClient:
# Singleton instance (configured from settings)
_fava_client: Optional[FavaClient] = None
# Set by init_fava_client; await for background tasks that must not run
# before the client exists (otherwise they raise "Fava client not initialized"
# during the first ~500ms of startup).
_fava_client_ready: asyncio.Event = asyncio.Event()
def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0):
@ -1940,9 +2070,21 @@ def init_fava_client(fava_url: str, ledger_slug: str, timeout: float = 10.0):
"""
global _fava_client
_fava_client = FavaClient(fava_url, ledger_slug, timeout)
_fava_client_ready.set()
logger.info(f"Fava client initialized: {fava_url}/{ledger_slug}")
async def wait_for_fava_client() -> FavaClient:
"""Block until init_fava_client() has been called, then return the client.
Use this from background tasks started in libra_start() they otherwise
race the fire-and-forget _init_fava() coroutine and crash with
"Fava client not initialized" on first iteration.
"""
await _fava_client_ready.wait()
return get_fava_client()
def get_fava_client() -> FavaClient:
"""
Get the configured Fava client.

View file

@ -48,6 +48,17 @@ class CreateAccount(BaseModel):
is_virtual: bool = False # Set to True to create virtual parent account
class CreateChartAccount(BaseModel):
"""Admin-created chart-of-accounts entry written to accounts/chart.beancount."""
name: str # Full hierarchical account name, e.g. "Expenses:Services:Domain"
# Optional currency constraint. Omitted by the UI: an Open directive needs
# no currency list, and constraining it would reject postings in other
# currencies (the CAD/GBP/JPY bean-check errors we saw on user accounts).
# None → unconstrained Open; a list → explicit constraint for API callers.
currencies: Optional[list[str]] = None
description: Optional[str] = None
class EntryLine(BaseModel):
id: str
journal_entry_id: str
@ -89,6 +100,11 @@ class UserBalance(BaseModel):
user_id: str
balance: int # positive = libra owes user, negative = user owes libra
accounts: list[Account] = []
# Per-account breakdown surfaced from get_user_balance_bql so UIs (libra
# extension dashboard + webapp) can render Payable / Receivable / Credit
# as distinct line items. Each entry: {"account": str, "sats": int,
# "eur": Decimal}. Wired up for libra-#41's display contract.
account_balances: list[dict] = []
fiat_balances: dict[str, Decimal] = {} # e.g. {"EUR": Decimal("250.0"), "USD": Decimal("100.0")}
# Lifetime totals (original entries only; not net of reconciliation)
total_expenses_sats: int = 0

View file

@ -69,6 +69,13 @@ window.app = Vue.createApp({
userWalletId: '',
loading: false
},
addAccountDialog: {
show: false,
rootType: 'Expenses',
subPath: '',
description: '',
loading: false
},
receivableDialog: {
show: false,
selectedUser: '',
@ -286,6 +293,16 @@ window.app = Vue.createApp({
})
return options
},
accountRootTypes() {
// The five Beancount root account types — the only valid parents.
// Mirrors the server's _VALID_ACCOUNT_PREFIXES.
return ['Assets', 'Liabilities', 'Equity', 'Income', 'Expenses']
},
addAccountFullName() {
const sub = (this.addAccountDialog.subPath || '').trim().replace(/^:+|:+$/g, '')
if (!this.addAccountDialog.rootType || !sub) return ''
return `${this.addAccountDialog.rootType}:${sub}`
},
userOptions() {
const options = []
this.users.forEach(user => {
@ -566,6 +583,55 @@ window.app = Vue.createApp({
this.syncingAccounts = false
}
},
showAddAccountDialog() {
this.addAccountDialog.rootType = 'Expenses'
this.addAccountDialog.subPath = ''
this.addAccountDialog.description = ''
this.addAccountDialog.show = true
},
async submitAddAccount() {
const name = this.addAccountFullName
if (!name) {
this.$q.notify({type: 'warning', message: 'Enter a sub-account name'})
return
}
// Each segment under the root must be a valid Beancount account
// component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase
// letter or digit, then letters/digits/hyphens (Unicode letters allowed).
const badSegment = name.split(':').slice(1).find(
seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg)
)
if (badSegment !== undefined) {
this.$q.notify({
type: 'warning',
message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit`
})
return
}
this.addAccountDialog.loading = true
try {
const {data} = await LNbits.api.request(
'POST',
'/libra/api/v1/admin/accounts',
this.g.user.wallets[0].adminkey,
{
name,
description: this.addAccountDialog.description || null
}
)
this.$q.notify({
type: 'positive',
message: `Account ${data.account_name} created` +
(data.synced_to_libra_db ? '' : ' (sync pending)')
})
this.addAccountDialog.show = false
await this.loadAccounts()
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.addAccountDialog.loading = false
}
},
showSettingsDialog() {
this.settingsDialog.libraWalletId = this.settings?.libra_wallet_id || ''
this.settingsDialog.favaUrl = this.settings?.fava_url || 'http://localhost:3333'
@ -1645,6 +1711,31 @@ window.app = Vue.createApp({
isIncomeEntry(entry) {
return Array.isArray(entry.tags) && entry.tags.includes('income-entry')
},
// Per-currency split for multi-currency balances. Sign convention from the
// super-user perspective: positive fiat = user owes Libra (Receivable),
// negative fiat = Libra owes user (Payable). Distinct currencies can't be
// netted across each other (no spot rate), so we render them grouped by
// direction instead of one collapsed label.
owesYouFiat(fiatBalances) {
if (!fiatBalances) return {}
return Object.fromEntries(
Object.entries(fiatBalances).filter(([_, amount]) => Number(amount) > 0.005)
)
},
youOweFiat(fiatBalances) {
if (!fiatBalances) return {}
return Object.fromEntries(
Object.entries(fiatBalances)
.filter(([_, amount]) => Number(amount) < -0.005)
.map(([cur, amount]) => [cur, Math.abs(Number(amount))])
)
},
hasOwesYouFiat(fiatBalances) {
return Object.keys(this.owesYouFiat(fiatBalances)).length > 0
},
hasYouOweFiat(fiatBalances) {
return Object.keys(this.youOweFiat(fiatBalances)).length > 0
},
formatFiat(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
@ -1682,6 +1773,10 @@ window.app = Vue.createApp({
if (entry.tags && entry.tags.includes('equity-contribution')) return true
if (entry.account && entry.account.includes('Equity')) return true
return false
},
isVoided(entry) {
// Voided entries keep '!' flag and carry a 'voided' tag (libra convention).
return Array.isArray(entry.tags) && entry.tags.includes('voided')
}
},
async created() {

View file

@ -134,8 +134,15 @@ async def wait_for_account_sync():
Background task that periodically syncs accounts from Beancount to Libra DB.
Runs hourly to ensure Libra DB stays in sync with Beancount.
Blocks on `wait_for_fava_client()` before the first iteration so we don't
race the fire-and-forget `_init_fava()` started in `libra_start()` and
fail the first sync with "Fava client not initialized".
"""
from .fava_client import wait_for_fava_client
logger.info("[LIBRA] Account sync background task started")
await wait_for_fava_client()
while True:
try:

View file

@ -187,16 +187,27 @@
</template>
<template v-slot:body-cell-balance="props">
<q-td :props="props">
<div :class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
<!-- User owes you (org), per currency -->
<div v-if="hasOwesYouFiat(props.row.fiat_balances)" class="text-positive">
<div v-for="(amount, currency) in owesYouFiat(props.row.fiat_balances)" :key="'oy-' + currency">
Owes you {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
</div>
</div>
<div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption">
<span v-for="(amount, currency) in props.row.fiat_balances" :key="currency" class="q-mr-sm">
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %}
</span>
<!-- You (org) owe user, per currency -->
<div v-if="hasYouOweFiat(props.row.fiat_balances)" class="text-negative">
<div v-for="(amount, currency) in youOweFiat(props.row.fiat_balances)" :key="'yo-' + currency">
You owe {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
</div>
</div>
<div class="text-caption text-grey">
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }}{% endraw %}
<!-- Fallback when there are no fiat balances (sats-only entries) -->
<div v-if="!hasOwesYouFiat(props.row.fiat_balances) && !hasYouOweFiat(props.row.fiat_balances)"
:class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }} {{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
</div>
<!-- Net sats footnote (current-rate-derived; can't be netted across currencies) -->
<div v-if="hasOwesYouFiat(props.row.fiat_balances) || hasYouOweFiat(props.row.fiat_balances)"
class="text-caption text-grey q-mt-xs">
Net (current rates): {% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats {{ props.row.balance > 0 ? '(receivable)' : '(payable)' }}{% endraw %}
</div>
</q-td>
</template>
@ -486,7 +497,10 @@
<!-- Status Flag Column -->
<template v-slot:body-cell-flag="props">
<q-td :props="props">
<q-icon v-if="props.row.flag === '*'" name="check_circle" color="positive" size="sm">
<q-icon v-if="isVoided(props.row)" name="cancel" color="grey" size="sm">
<q-tooltip>Voided</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.flag === '*'" name="check_circle" color="positive" size="sm">
<q-tooltip>Cleared</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.flag === '!'" name="pending" color="orange" size="sm">
@ -495,9 +509,6 @@
<q-icon v-else-if="props.row.flag === '#'" name="flag" color="red" size="sm">
<q-tooltip>Flagged</q-tooltip>
</q-icon>
<q-icon v-else-if="props.row.flag === 'x'" name="cancel" color="grey" size="sm">
<q-tooltip>Voided</q-tooltip>
</q-icon>
</q-td>
</template>
@ -846,7 +857,20 @@
<!-- Chart of Accounts -->
<q-card>
<q-card-section>
<h6 class="q-my-none q-mb-md">Chart of Accounts</h6>
<div class="row items-center q-mb-md">
<h6 class="q-my-none">Chart of Accounts</h6>
<q-space></q-space>
<q-btn
v-if="isSuperUser"
unelevated
dense
size="sm"
color="primary"
icon="add"
label="Add Account"
@click="showAddAccountDialog"
></q-btn>
</div>
<q-list dense v-if="accounts.length > 0">
<q-item v-for="account in accounts" :key="account.id">
<q-item-section>
@ -1221,6 +1245,63 @@
</q-card>
</q-dialog>
<!-- Add Account Dialog -->
<q-dialog v-model="addAccountDialog.show" position="top">
<q-card v-if="addAccountDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="submitAddAccount" class="q-gutter-md">
<div class="text-h6 q-mb-md">Add Account</div>
<q-select
filled
dense
v-model="addAccountDialog.rootType"
:options="accountRootTypes"
label="Account Type *"
hint="Top-level category — the only valid parents"
></q-select>
<q-input
filled
dense
v-model.trim="addAccountDialog.subPath"
label="Sub-account *"
placeholder="e.g., Vehicle:Gas"
hint="Path under the type. Use ':' to nest; capitalize each part."
></q-input>
<div v-if="addAccountFullName" class="text-caption text-grey">
Will create: <span class="text-weight-medium">{% raw %}{{ addAccountFullName }}{% endraw %}</span>
</div>
<q-input
filled
dense
v-model.trim="addAccountDialog.description"
label="Description"
placeholder="Optional notes about this account"
></q-input>
<div class="text-caption text-grey">
Creates an Open directive in the Beancount ledger and syncs it into Libra
so permissions can be granted. Per-user accounts are managed automatically.
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:loading="addAccountDialog.loading"
:disable="!addAccountFullName"
>
Create Account
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- Receivable Dialog -->
<q-dialog v-model="receivableDialog.show" position="top">
<q-card v-if="receivableDialog.show" class="q-pa-lg q-pt-xl lnbits__dialog-card">

82
tests/README.md Normal file
View file

@ -0,0 +1,82 @@
# Libra extension tests
Integration tests covering the user- and admin-facing flows of the libra extension. Tests run against a real `fava` subprocess and a full LNbits app so they catch behaviour that mocks would miss (BQL semantics, Beancount arithmetic, multi-currency aggregation, HTTP boundary).
## Layout
- `conftest.py` — session-scoped Fava subprocess + LNbits app + user/wallet fixtures.
- `helpers.py` — high-level wrappers for the common API flows (`post_expense`, `settle_receivable`, `approve_manual_payment_request`, …). One per intention, so test bodies read as sequences of actions rather than HTTP calls.
- `test_smoke.py` — single end-to-end test; run first to validate the harness.
- `test_<area>_api.py` — per-flow coverage (entries, balances, settlement, manual payment requests, lightning, reconciliation, settings/auth, void/reject).
- `test_unit.py` — pure functions (`beancount_format`, `account_utils`, `core/validation`); no harness.
## Prerequisites
The harness requires `fava` on PATH. On NixOS:
```bash
nix-shell -p python3Packages.fava
```
Inside the regtest container `fava` is already provisioned.
## Running
The suite targets the **`lnbits/dev` worktree** (`~/dev/lnbits/dev`) — it
relies on dev-branch modules (`lnbits.core.signers`, the bunker work) that
`main` doesn't carry. A known-good invocation from scratch:
```bash
# One-time: build a venv with lnbits (dev) + test deps + fava
nix-shell -p uv --run "uv venv /tmp/libra-test-venv --python 3.12 && \
uv pip install --python /tmp/libra-test-venv/bin/python \
-e ~/dev/lnbits/dev pytest asgi-lifespan fava"
# Run (each invocation gets a fresh data folder — REQUIRED, see gotchas)
cd ~/dev/lnbits/dev && \
env LNBITS_KEY_MASTER=$(openssl rand -hex 32) \
LNBITS_DATA_FOLDER=$(mktemp -d -t libra-test-data-XXXX) \
LNBITS_EXTENSIONS_PATH=$HOME/dev/shared \
PYTHONPATH=$HOME/dev/shared/extensions:. \
PATH=/tmp/libra-test-venv/bin:$PATH \
/tmp/libra-test-venv/bin/pytest ~/dev/shared/extensions/libra/tests -q
```
```bash
# Smoke test only (validate the harness before running everything)
... pytest path/to/libra/tests/test_smoke.py
# One area
... pytest path/to/libra/tests/test_balances_api.py
# Single test, verbose
... pytest path/to/libra/tests/test_balances_api.py::test_mixed_income_expense_nets_correctly -v
```
### Environment gotchas (each cost a failed run on 2026-06-12)
- **`LNBITS_EXTENSIONS_PATH` is the *parent* of an `extensions/` dir** —
lnbits scans `{path}/extensions/` (`lnbits/app.py`,
`build_all_installed_extensions_list`). For extensions at
`~/dev/shared/extensions/libra`, pass `~/dev/shared`. Pointing it at
`~/dev/shared/extensions` makes libra invisible: zero extensions install,
migrations never run, and every test errors with
`no such table: extension_settings`.
- **Set `LNBITS_DATA_FOLDER` to a fresh temp dir explicitly.** The
conftest's `os.environ.setdefault` redirect is not always effective;
reusing a previous run's database fails `first_install` with
"Username already exists" during app-fixture setup.
- **`LNBITS_KEY_MASTER` (32-byte hex) is mandatory on lnbits dev** — the
signer migration aborts startup without it (issue lnbits#9
encrypt-at-rest). Any random value is fine for tests.
- **lnbits `main` does not work**: extensions importing
`lnbits.core.signers` fail to load, and libra's app fixture errors.
The Fava subprocess starts once per session (~1-2s) and is shared across tests; each test creates its own LNbits user so the shared ledger doesn't cause inter-test interference.
## Conventions
- **Tests assert intent, not shape.** Use the helpers in `helpers.py` for the request and assert on the *meaning* of the response (balance values, account names, settlement state), not on incidental keys in the JSON. This keeps tests resilient to non-behavioural API tweaks.
- **Currency-handling assertions use `pytest.approx`** for `Decimal`/`float` tolerance.
- **One canonical happy path per flow, plus boundary cases that matter** (voided entries excluded, pending entries excluded, cross-user isolation, auth gate rejection). Don't over-matrix.
- **Each test creates its own users** via the function-scoped `libra_user` / `libra_user_b` fixtures. The ledger is session-shared and accumulates entries; test isolation comes from unique user IDs, not ledger resets.

0
tests/__init__.py Normal file
View file

714
tests/conftest.py Normal file
View file

@ -0,0 +1,714 @@
"""Libra test infrastructure.
Brings up:
- A session-scoped Fava subprocess against a temp .beancount ledger
- A session-scoped LNbits FastAPI app with Libra extension activated
- The Libra FavaClient pointed at the test Fava instance
- Function-scoped user/wallet fixtures, plus a session-scoped superuser
Run from the LNbits source root::
PYTHONPATH=. pytest lnbits/extensions/libra/tests
Requires the `fava` binary on PATH. On NixOS::
nix-shell -p python3Packages.fava --run "pytest lnbits/extensions/libra/tests"
"""
import os
import tempfile
# IMPORTANT: configure the LNbits data folder BEFORE importing anything from
# lnbits. `lnbits/db.py` constructs Database instances at module-import time
# and freezes `settings.lnbits_data_folder` at that moment — overriding it in
# a fixture later is too late to redirect the SQLite files.
_SESSION_DATA_DIR = tempfile.mkdtemp(prefix="libra-lnbits-data-")
os.environ.setdefault("LNBITS_DATA_FOLDER", _SESSION_DATA_DIR)
# Lightning-invoice tests need a non-VoidWallet backend, but switching to
# FakeWallet here causes the LifespanManager teardown to hang indefinitely
# (the Lightning subsystem's background tasks don't unwind cleanly under
# anyio's TestRunner). Keeping VoidWallet — Lightning-invoice-generation
# tests are marked `skip` until a separate LN-harness strategy lands.
import asyncio # noqa: E402
import copy # noqa: E402
import inspect # noqa: E402
import shutil # noqa: E402
import socket # noqa: E402
import subprocess # noqa: E402
import time # noqa: E402
from pathlib import Path # noqa: E402
from typing import AsyncIterator, Iterator # noqa: E402
from uuid import uuid4 # noqa: E402
import httpx
import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from lnbits.app import create_app
from lnbits.core.crud import (
create_wallet,
delete_account,
get_user,
)
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account
from lnbits.core.views.auth_api import first_install
from lnbits.settings import AuthMethods, EditableSettings, Settings
from lnbits.settings import settings as lnbits_settings
LEDGER_SLUG = "libra-test"
# ---------------------------------------------------------------------------
# Settings overrides
# ---------------------------------------------------------------------------
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
f
for f in Settings.readonly_fields()
if f != "super_user"
}
| {
name
for name in inspect.signature(EditableSettings).parameters
if not name.startswith("_")
}
)
)
def _settings_cleanup(settings: Settings) -> None:
"""Reset mutable settings to their pre-test snapshot, then re-apply
test-specific overrides on top so each test starts from the same baseline.
Mirrors the shape of lnbits/main/tests/conftest.py: restore PURE, then
set the values the tests rely on. Without this, autouse cleanup wipes
out everything the session-scoped `settings` fixture set up.
"""
for field in _PURE_SETTINGS_FIELDS:
setattr(settings, field, getattr(_PURE_SETTINGS, field))
# Test-specific overrides — these must survive cleanup between tests.
settings.auth_https_only = False
settings.lnbits_data_folder = _SESSION_DATA_DIR
settings.lnbits_admin_extensions = [] # libra is a multi-user extension, not admin-only
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = False
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_require_user_activation = False
settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = []
# Keep the rate limiter disabled across per-test settings resets (the
# limiter itself is fixed at app-creation time, but keep the value coherent).
settings.lnbits_rate_limit_no = 1_000_000
@pytest.fixture(scope="session")
def anyio_backend() -> str:
return "asyncio"
@pytest.fixture(scope="session")
def settings() -> Iterator[Settings]:
"""LNbits settings configured for the libra test session.
Mirrors lnbits/main/tests/conftest.py: do NOT pre-set super_user; the boot
sequence assigns a UUID and creates the matching account. The `super_user`
fixture reads settings.super_user after first_install completes.
The data folder was set via LNBITS_DATA_FOLDER at the top of this module
so the lnbits/db.py import-time directory creation lands in the right
place; nothing to do here except make sure it stays consistent.
"""
lnbits_settings.auth_https_only = False
lnbits_settings.lnbits_admin_extensions = ["libra"]
lnbits_settings.lnbits_data_folder = _SESSION_DATA_DIR
lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = False
# The full suite fires >200 requests/minute; the default rate limit (200/min)
# otherwise 429s fixture setup intermittently. The limiter is built once at
# app creation from this value (lnbits/app.py register_new_ratelimiter), and
# this fixture runs before the `app` fixture, so raising it here disables it
# for the session.
lnbits_settings.lnbits_rate_limit_no = 1_000_000
yield lnbits_settings
@pytest.fixture(autouse=True)
def _per_test_settings_reset(settings: Settings) -> Iterator[None]:
_settings_cleanup(settings)
yield
_settings_cleanup(settings)
# ---------------------------------------------------------------------------
# Fava subprocess
# ---------------------------------------------------------------------------
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
MINIMAL_LEDGER = """; Test ledger for Libra extension integration tests
; Title must slugify to match LEDGER_SLUG Fava derives the URL slug from this.
option "title" "libra-test"
option "operating_currency" "EUR"
option "operating_currency" "SATS"
option "render_commas" "TRUE"
2020-01-01 commodity EUR
2020-01-01 commodity SATS
2020-01-01 open Assets:Lightning:Balance EUR,SATS
2020-01-01 open Assets:Bitcoin:Lightning EUR,SATS
2020-01-01 open Assets:Cash EUR,SATS
2020-01-01 open Equity:Opening-Balances EUR,SATS
2020-01-01 open Income:Generic EUR,SATS
2020-01-01 open Expenses:Generic EUR,SATS
include "accounts/chart.beancount"
include "accounts/users.beancount"
"""
# Split-layout include targets, mirroring the production fava layout
# (aiolabs/server-deploy#4). libra's fava_client routes Open directives by
# account name (fava_client._infer_target_file): per-user accounts
# (:User-xxxxxxxx) to accounts/users.beancount, everything else to
# accounts/chart.beancount. Both must exist as Fava *source* files (i.e. be
# included) or /api/source writes 500 with "non-source file". The title stays
# in the root ledger above so Fava's slug still matches LEDGER_SLUG (scalar
# options don't propagate from includes — see aiolabs/server-deploy#9).
CHART_SEED = "; Admin-mutable chart of accounts (libra appends Open directives).\n"
USERS_SEED = "; Per-user account opens (libra appends at signup).\n"
@pytest.fixture(scope="session")
def fava_ledger_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Session-scoped split ledger Fava reads from: a root file that includes
accounts/chart.beancount (admin add-account target) and
accounts/users.beancount (per-user opens target)."""
ledger_dir = tmp_path_factory.mktemp("libra-ledger")
(ledger_dir / "accounts").mkdir()
(ledger_dir / "accounts" / "chart.beancount").write_text(CHART_SEED)
(ledger_dir / "accounts" / "users.beancount").write_text(USERS_SEED)
ledger = ledger_dir / f"{LEDGER_SLUG}.beancount"
ledger.write_text(MINIMAL_LEDGER)
return ledger
@pytest.fixture(scope="session")
def fava_process(fava_ledger_path: Path) -> Iterator[str]:
"""Spawn fava as a subprocess, yield its base URL, terminate on teardown."""
fava_bin = shutil.which("fava")
if not fava_bin:
pytest.skip(
"fava not found on PATH; "
"install with `pip install fava` or `nix-shell -p python3Packages.fava`"
)
port = _find_free_port()
base_url = f"http://127.0.0.1:{port}"
proc = subprocess.Popen(
[
fava_bin,
"--host", "127.0.0.1",
"--port", str(port),
str(fava_ledger_path),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env={**os.environ, "BEANCOUNT_FILE": str(fava_ledger_path)},
)
deadline = time.monotonic() + 15.0
ready = False
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(
f"fava exited early with returncode {proc.returncode}"
)
try:
r = httpx.get(f"{base_url}/{LEDGER_SLUG}/api/changed", timeout=0.5)
if r.status_code == 200:
ready = True
break
except httpx.RequestError:
pass
time.sleep(0.1)
if not ready:
proc.terminate()
raise RuntimeError("fava did not become ready within 15s")
try:
yield base_url
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
# ---------------------------------------------------------------------------
# LNbits app + Libra extension
# ---------------------------------------------------------------------------
def _import_libra(submodule: str):
"""Import a libra submodule under whichever path the active LNbits setup uses.
LNbits resolves an extension's module name dynamically: `lnbits.extensions.<ext>`
when extensions live in the default `lnbits/extensions/` directory, or just
`<ext>` when `LNBITS_EXTENSIONS_PATH` points elsewhere. Tests should work in
both setups.
"""
import importlib
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{submodule}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(
f"libra.{submodule}: tried 'lnbits.extensions.libra.{submodule}' and "
f"'libra.{submodule}'. Is LNBITS_EXTENSIONS_PATH pointing at the libra parent dir, "
f"or is libra symlinked into lnbits/extensions/?"
)
async def _enable_libra_for_user(user_id: str) -> None:
"""Set libra to active in the user_extensions table for `user_id`.
LNbits gates every extension API path through `check_user_extension_access`,
which requires the calling user to have the extension marked active in
`user_extensions`. New accounts have no extensions enabled, so the API
rejects them with 403 until we flip the row.
"""
from lnbits.core.services.users import update_user_extensions
await update_user_extensions(user_id, ["libra"])
async def _activate_libra(fava_url: str, super_user_id: str) -> None:
"""Point libra at the test Fava instance and enable it for the superuser.
Libra is auto-discovered + auto-installed at LNbits boot via
`LNBITS_EXTENSIONS_PATH`, so its router is already mounted, migrations
already ran, and `libra_start()` already initialised a FavaClient with
the default `http://localhost:3333/libra-ledger` URL. Three things still
need doing:
1. Redirect the FavaClient at the test Fava instance.
2. Persist the override in `extension_settings` so any caller that goes
through `services.get_settings()` picks it up too.
3. Enable libra for the superuser per-user activation isn't automatic.
"""
libra_fava_client = _import_libra("fava_client")
libra_crud = _import_libra("crud")
libra_fava_client.init_fava_client(
fava_url=fava_url,
ledger_slug=LEDGER_SLUG,
timeout=5.0,
)
await libra_crud.db.execute("DELETE FROM extension_settings")
await libra_crud.db.execute(
"""
INSERT INTO extension_settings (id, fava_url, fava_ledger_slug, fava_timeout)
VALUES (:id, :fava_url, :slug, :timeout)
""",
{
"id": uuid4().hex,
"fava_url": fava_url,
"slug": LEDGER_SLUG,
"timeout": 5.0,
},
)
await _enable_libra_for_user(super_user_id)
@pytest.fixture(scope="session")
async def app(settings: Settings, fava_process: str) -> AsyncIterator:
"""Session-scoped LNbits app with Libra activated."""
app = create_app()
# First-time startup runs all core + libra migrations (~3-5s on cold disk),
# plus libra_start() initialises the Fava client and background tasks.
# Bump the timeout well above asgi_lifespan's 10s default so a slow
# migration step or Fava startup race doesn't spuriously fail the session.
async with LifespanManager(app, startup_timeout=60, shutdown_timeout=20) as manager:
settings.first_install = True
# pragma: allowlist secret start
await first_install(
UpdateSuperuserPassword(
username="superadmin",
password="secret1234",
password_repeat="secret1234",
first_install_token=settings.first_install_token,
)
# pragma: allowlist secret end
)
await _activate_libra(
fava_url=fava_process,
super_user_id=settings.super_user,
)
yield manager.app
@pytest.fixture(scope="session")
async def client(app, settings: Settings) -> AsyncIterator[AsyncClient]:
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
yield client
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def super_user(app, settings: Settings):
"""The superadmin account created by first_install."""
# first_install sets settings.super_user to the actual ID it created.
user = await get_user(settings.super_user)
assert user is not None, "superadmin was not created by first_install"
return user
@pytest.fixture
async def libra_user(app):
"""A fresh non-admin user with a wallet. Function-scoped — each test gets its own.
Libra is enabled in the user_extensions table for this user so the API
doesn't 403 with "Extension 'libra' not enabled."
"""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
yield user, wallet
# Cleanup: best-effort
try:
await delete_account(user.id)
except Exception:
pass
@pytest.fixture
async def libra_user_b(app):
"""A second fresh non-admin user, for tests that need cross-user assertions."""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass
# ---------------------------------------------------------------------------
# Auth headers
# ---------------------------------------------------------------------------
async def _user_bearer(client: AsyncClient, user_id: str) -> dict:
"""Bearer headers for a non-admin user via the `/auth/usr` user-id-only flow.
Admin/super accounts are blocked from this flow (LNbits forces them to
use username+password); regular users use it freely. Required for libra
endpoints that depend on `check_user_exists` (Bearer/cookie/usr) rather
than on a wallet API key.
"""
r = await client.post("/api/v1/auth/usr", json={"usr": user_id})
client.cookies.clear()
token = r.json().get("access_token")
assert token, f"user-id login failed: {r.status_code} {r.text}"
return {
"Authorization": f"Bearer {token}",
"Content-type": "application/json",
}
async def _superadmin_bearer(client: AsyncClient) -> dict:
"""Bearer headers for the superadmin via username+password auth.
`/api/v1/auth/usr` (user-id-only auth) is rejected for admin users
LNbits enforces username+password for accounts in `lnbits_admin_users`
or the super_user account. So super-user fixtures use the username
flow that `first_install` configured.
"""
r = await client.post(
"/api/v1/auth", json={"username": "superadmin", "password": "secret1234"}
)
client.cookies.clear()
token = r.json().get("access_token")
assert token, f"superadmin login failed: {r.status_code} {r.text}"
return {
"Authorization": f"Bearer {token}",
"Content-type": "application/json",
}
@pytest.fixture
async def super_user_bearer_headers(client: AsyncClient, super_user) -> dict:
"""Bearer headers for the few endpoints that use LNbits `check_super_user`.
The `/libra/api/v1/settings` endpoints (and other libra paths that take
`User = Depends(check_super_user)`) require a Bearer token from
username+password login. Most other libra admin endpoints use the
wallet-admin-key auth flow use `super_user_headers` for those.
"""
return await _superadmin_bearer(client)
@pytest.fixture
async def super_user_headers(super_user, libra_wallet) -> dict:
"""Admin-key headers for libra admin endpoints that use the wallet auth flow.
Libra's `require_super_user` dependency takes a `WalletTypeInfo` via
`require_admin_key` and verifies the wallet's owner is the LNbits
super user. So we authenticate by sending the super-user-owned wallet's
admin key as `X-Api-Key`.
"""
return admin_key_headers(libra_wallet)
def invoice_key_headers(wallet) -> dict:
"""Wallet invoice-key headers (X-Api-Key) — for require_invoice_key endpoints."""
return {"X-Api-Key": wallet.inkey, "Content-type": "application/json"}
def admin_key_headers(wallet) -> dict:
"""Wallet admin-key headers (X-Api-Key) — for require_admin_key endpoints."""
return {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
# ---------------------------------------------------------------------------
# Libra-specific session setup: wallet, accounts
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
async def libra_wallet(
app, settings: Settings, super_user, fava_process: str, client: AsyncClient,
):
"""Session-scoped: create a wallet for the super user and register it
as the libra wallet in extension_settings.
Most flows (expense, income, settle, pay-user) refuse to operate until
this is set. Session-scoped because it's a one-time setup that any test
can share.
"""
wallet = await create_wallet(user_id=super_user.id, wallet_name="libra-main")
# Configure libra_wallet_id via the settings API so the in-memory cache
# (services.update_settings) refreshes too.
#
# Critical: include fava_url + fava_ledger_slug in the body so that
# services.update_settings()'s re-init of the FavaClient doesn't reset
# us to the default `http://localhost:3333/libra-ledger`. The settings
# endpoint rewrites the global FavaClient from the body's contents on
# every call.
headers = await _superadmin_bearer(client)
r = await client.put(
"/libra/api/v1/settings",
headers=headers,
json={
"libra_wallet_id": wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": LEDGER_SLUG,
},
)
assert r.status_code == 200, f"libra_wallet setup failed: {r.status_code} {r.text}"
return wallet
@pytest.fixture(scope="session")
async def standard_accounts(app, super_user, libra_wallet, client: AsyncClient):
"""Session-scoped: create a small set of accounts used across tests.
Returns a dict of {short_name: account_dict}. Each account has at least
`id` and `name` keys.
"""
# `/accounts` POST is gated by `require_super_user` (libra-level, wallet
# admin-key flow), so we authenticate with the super-user's wallet key.
headers = admin_key_headers(libra_wallet)
async def _list_lookup(name: str) -> dict | None:
r = await client.get("/libra/api/v1/accounts", headers=headers)
if r.status_code != 200:
return None
for a in r.json():
if a.get("name") == name:
return a
return None
async def _create(name: str, account_type: str) -> dict:
# Get-then-create. Some accounts (Assets:Cash, etc.) are auto-synced
# into libra's DB from the Beancount Open directives by the account-sync
# background task. Posting a duplicate raises IntegrityError → 500;
# checking first avoids the race and the noisy error log.
existing = await _list_lookup(name)
if existing:
return existing
r = await client.post(
"/libra/api/v1/accounts",
headers=headers,
json={"name": name, "account_type": account_type},
)
if r.status_code == 201:
return r.json()
# Lost the race between our GET and POST — sync ran in between.
existing = await _list_lookup(name)
if existing:
return existing
raise AssertionError(f"create account {name}: {r.status_code} {r.text}")
return {
"expense_food": await _create("Expenses:Test:Food", "expense"),
"expense_supplies": await _create("Expenses:Test:Supplies", "expense"),
"revenue_rent": await _create("Income:Test:Rent", "revenue"),
"revenue_fees": await _create("Income:Test:Fees", "revenue"),
# Cash for revenue/settlement payment-method tests. Already declared
# as an Open directive in the Beancount file (see MINIMAL_LEDGER),
# but needs a libra-DB row too because the revenue endpoint validates
# payment-method-account via libra's local lookup.
"assets_cash": await _create("Assets:Cash", "asset"),
# Lightning balance account — the manual-payment-request approve
# endpoint posts the payment leg against this. Open directive lives
# in MINIMAL_LEDGER's Assets:Lightning:Balance, but the API code
# looks up `Assets:Bitcoin:Lightning` specifically.
"assets_lightning": await _create("Assets:Bitcoin:Lightning", "asset"),
}
# ---------------------------------------------------------------------------
# Configured user — wallet set + can submit expenses to the standard accounts
# ---------------------------------------------------------------------------
async def _grant_account_permissions(
client: AsyncClient,
libra_wallet,
user_id: str,
grants: list[tuple[str, str]],
) -> None:
"""Grant a list of (account_id, permission_type) pairs to a user.
Existing perms come back as 409; that's idempotent for fixture re-runs.
"""
headers = admin_key_headers(libra_wallet)
for account_id, permission_type in grants:
r = await client.post(
"/libra/api/v1/admin/permissions",
headers=headers,
json={
"user_id": user_id,
"account_id": account_id,
"permission_type": permission_type,
},
)
# 201 created; 409 if it already existed (idempotent).
assert r.status_code in (200, 201, 409), (
f"grant permission failed: {r.status_code} {r.text}"
)
@pytest.fixture
async def configured_user(
app, super_user, libra_wallet, standard_accounts, client: AsyncClient,
):
"""Function-scoped: fresh user with a wallet, configured for libra,
permitted to submit expenses to the standard test accounts.
Yields (user, wallet) ready to make any user-facing API call.
"""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
# User registers their own wallet with libra. The endpoint uses
# `check_user_exists` which accepts either a Bearer access token OR
# a `?usr=<id>` query param — we use the query param to avoid the
# cookie-state interleaving that bites when two configured_user
# fixtures stack in the same test.
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}"
# Grant submit_expense on every expense account, submit_income on every
# revenue account, so tests can hit either user-side entry endpoint.
grants = [
(a["id"], "submit_expense")
for k, a in standard_accounts.items() if k.startswith("expense_")
] + [
(a["id"], "submit_income")
for k, a in standard_accounts.items() if k.startswith("revenue_")
]
await _grant_account_permissions(client, libra_wallet, user.id, grants)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass
@pytest.fixture
async def configured_user_b(
app, super_user, libra_wallet, standard_accounts, client: AsyncClient,
):
"""A second configured user for cross-user tests."""
user = await create_user_account()
wallet = await create_wallet(
user_id=user.id, wallet_name=f"libra-test-{uuid4().hex[:6]}"
)
await _enable_libra_for_user(user.id)
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"user wallet setup failed: {r.status_code} {r.text}"
grants = [
(a["id"], "submit_expense")
for k, a in standard_accounts.items() if k.startswith("expense_")
] + [
(a["id"], "submit_income")
for k, a in standard_accounts.items() if k.startswith("revenue_")
]
await _grant_account_permissions(client, libra_wallet, user.id, grants)
yield user, wallet
try:
await delete_account(user.id)
except Exception:
pass

428
tests/helpers.py Normal file
View file

@ -0,0 +1,428 @@
"""Convenience helpers for Libra integration tests.
Wrap the most common multi-step flows so each test reads as a sequence of
intentions rather than as a sequence of HTTP calls. Every helper returns the
parsed JSON response and asserts a successful status code tests that want
to assert on failures should call the endpoint directly.
All amounts are passed as Decimal (or numeric string). Currency goes as a
separate ISO code field this matches `models.ExpenseEntry` / `ReceivableEntry`
/ `SettleReceivable` / `PayUser` etc., which all carry `amount: Decimal` and
`currency: Optional[str]` independently.
"""
from decimal import Decimal
from typing import Any, Optional, Union
from httpx import AsyncClient, Response
Amount = Union[Decimal, int, float, str]
def _amount(value: Amount) -> str:
"""Coerce amount to a JSON-serialisable string Pydantic will parse as Decimal."""
return str(value)
# ---------------------------------------------------------------------------
# Setup — libra wallet + per-user wallet + accounts + permissions
# ---------------------------------------------------------------------------
async def configure_libra_wallet(
client: AsyncClient,
*,
super_user_headers: dict,
libra_wallet_id: str,
) -> dict:
"""Super user sets the libra wallet (required before any entry endpoint works)."""
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_headers,
json={"libra_wallet_id": libra_wallet_id},
)
assert r.status_code == 200, f"configure_libra_wallet failed: {r.status_code} {r.text}"
return r.json()
async def configure_user_wallet(
client: AsyncClient,
*,
wallet_inkey: str,
user_wallet_id: str,
) -> dict:
"""User sets their personal wallet (required before they can submit entries)."""
r = await client.put(
"/libra/api/v1/user/wallet",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={"user_wallet_id": user_wallet_id},
)
assert r.status_code == 200, f"configure_user_wallet failed: {r.status_code} {r.text}"
return r.json()
async def create_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
account_type: str,
description: Optional[str] = None,
) -> dict:
"""Super user creates an account in the libra local DB.
`account_type` is one of "asset", "liability", "equity", "revenue", "expense".
"""
r = await client.post(
"/libra/api/v1/accounts",
headers=super_user_headers,
json={
"name": name,
"account_type": account_type,
"description": description,
},
)
assert r.status_code == 201, f"create_account failed: {r.status_code} {r.text}"
return r.json()
async def grant_permission(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
account_id: str,
permission_type: str = "submit_expense",
) -> dict:
r = await client.post(
"/libra/api/v1/admin/permissions",
headers=super_user_headers,
json={
"user_id": user_id,
"account_id": account_id,
"permission_type": permission_type,
},
)
assert r.status_code == 201, f"grant_permission failed: {r.status_code} {r.text}"
return r.json()
async def add_chart_account(
client: AsyncClient,
*,
super_user_headers: dict,
name: str,
description: Optional[str] = None,
) -> Response:
"""Super user adds a chart-of-accounts entry via the admin endpoint
(POST /api/v1/admin/accounts). Returns the raw Response so callers can
assert on status codes (201 / 400 / 409 / 403)."""
body: dict[str, Any] = {"name": name}
if description is not None:
body["description"] = description
return await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json=body,
)
# ---------------------------------------------------------------------------
# Entries — user side
# ---------------------------------------------------------------------------
async def post_expense(
client: AsyncClient,
*,
wallet_inkey: str,
user_wallet_id: str,
amount: Amount,
description: str,
expense_account: str,
currency: Optional[str] = "EUR",
is_equity: bool = False,
reference: Optional[str] = None,
) -> dict[str, Any]:
"""User submits an expense — creates Liability (libra owes user) or Equity contribution.
Returns the created JournalEntry payload.
"""
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={
"description": description,
"amount": _amount(amount),
"expense_account": expense_account,
"user_wallet": user_wallet_id,
"currency": currency,
"is_equity": is_equity,
"reference": reference,
},
)
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
return r.json()
async def post_income(
client: AsyncClient,
*,
wallet_inkey: str,
amount: Amount,
description: str,
revenue_account: str,
currency: str = "EUR",
reference: Optional[str] = None,
) -> dict[str, Any]:
"""User submits income on libra's behalf — creates Receivable (user owes libra)."""
r = await client.post(
"/libra/api/v1/entries/income",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={
"description": description,
"amount": _amount(amount),
"revenue_account": revenue_account,
"currency": currency,
"reference": reference,
},
)
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
return r.json()
async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]:
r = await client.get(
"/libra/api/v1/entries/user",
headers={"X-Api-Key": wallet_inkey},
)
assert r.status_code == 200, f"list_user_entries failed: {r.status_code} {r.text}"
return r.json()
async def list_pending_entries(
client: AsyncClient, *, super_user_headers: dict,
) -> list[dict]:
"""Admin lists pending (`!`) entries awaiting approval."""
r = await client.get(
"/libra/api/v1/entries/pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"list_pending_entries failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Entries — admin side
# ---------------------------------------------------------------------------
async def post_receivable(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
amount: Amount,
description: str,
revenue_account: str,
currency: str = "EUR",
) -> dict[str, Any]:
"""Admin records a receivable — user owes libra."""
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=super_user_headers,
json={
"user_id": user_id,
"amount": _amount(amount),
"description": description,
"revenue_account": revenue_account,
"currency": currency,
},
)
assert r.status_code == 201, f"post_receivable failed: {r.status_code} {r.text}"
return r.json()
async def post_revenue(
client: AsyncClient,
*,
super_user_headers: dict,
amount: Amount,
description: str,
revenue_account: str,
payment_method_account: str,
currency: str = "EUR",
) -> dict[str, Any]:
r = await client.post(
"/libra/api/v1/entries/revenue",
headers=super_user_headers,
json={
"amount": _amount(amount),
"description": description,
"revenue_account": revenue_account,
"payment_method_account": payment_method_account,
"currency": currency,
},
)
assert r.status_code == 201, f"post_revenue failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Balances
# ---------------------------------------------------------------------------
async def get_balance(client: AsyncClient, *, wallet_inkey: str) -> dict[str, Any]:
"""Calling user's balance (or libra total if invoked by super user)."""
r = await client.get(
"/libra/api/v1/balance",
headers={"X-Api-Key": wallet_inkey},
)
assert r.status_code == 200, f"get_balance failed: {r.status_code} {r.text}"
return r.json()
async def get_all_balances(
client: AsyncClient, *, super_user_headers: dict
) -> list[dict]:
r = await client.get(
"/libra/api/v1/balances/all",
headers=super_user_headers,
)
assert r.status_code == 200, f"get_all_balances failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Settlement
# ---------------------------------------------------------------------------
async def settle_receivable(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
amount: Amount,
description: str = "Cash settlement",
payment_method: str = "cash",
currency: str = "EUR",
) -> dict[str, Any]:
"""Admin records that user paid libra (e.g. cash, bank transfer)."""
r = await client.post(
"/libra/api/v1/receivables/settle",
headers=super_user_headers,
json={
"user_id": user_id,
"amount": _amount(amount),
"description": description,
"payment_method": payment_method,
"currency": currency,
},
)
assert r.status_code == 200, f"settle_receivable failed: {r.status_code} {r.text}"
return r.json()
async def pay_user(
client: AsyncClient,
*,
super_user_headers: dict,
user_id: str,
amount: Amount,
description: str = "Libra pays user",
payment_method: str = "cash",
currency: str = "EUR",
) -> dict[str, Any]:
"""Admin records that libra paid user (e.g. cash, bank, lightning)."""
r = await client.post(
"/libra/api/v1/payables/pay",
headers=super_user_headers,
json={
"user_id": user_id,
"amount": _amount(amount),
"description": description,
"payment_method": payment_method,
"currency": currency,
},
)
assert r.status_code == 200, f"pay_user failed: {r.status_code} {r.text}"
return r.json()
# ---------------------------------------------------------------------------
# Manual payment requests
# ---------------------------------------------------------------------------
async def submit_manual_payment_request(
client: AsyncClient,
*,
wallet_inkey: str,
amount_sats: int,
description: str,
) -> dict[str, Any]:
"""User asks for libra to pay them via a manual (non-Lightning) route.
Body matches `CreateManualPaymentRequest`: amount in satoshis (no fiat
conversion at this endpoint), description for the admin to review.
"""
r = await client.post(
"/libra/api/v1/manual-payment-request",
headers={"X-Api-Key": wallet_inkey, "Content-type": "application/json"},
json={"amount": amount_sats, "description": description},
)
assert r.status_code in (200, 201), (
f"submit_manual_payment_request failed: {r.status_code} {r.text}"
)
return r.json()
async def approve_manual_payment_request(
client: AsyncClient, *, super_user_headers: dict, request_id: str,
) -> dict[str, Any]:
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{request_id}/approve",
headers=super_user_headers,
)
assert r.status_code == 200, (
f"approve_manual_payment_request failed: {r.status_code} {r.text}"
)
return r.json()
async def approve_entry(
client: AsyncClient, *, super_user_headers: dict, entry_id: str,
) -> dict[str, Any]:
"""Admin approves a pending journal entry, flipping its flag from `!` to `*`."""
r = await client.post(
f"/libra/api/v1/entries/{entry_id}/approve",
headers=super_user_headers,
)
assert r.status_code == 200, f"approve_entry failed: {r.status_code} {r.text}"
return r.json()
async def reject_entry(
client: AsyncClient, *, super_user_headers: dict, entry_id: str,
) -> dict[str, Any]:
"""Admin rejects a pending journal entry, marking it #voided."""
r = await client.post(
f"/libra/api/v1/entries/{entry_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 200, f"reject_entry failed: {r.status_code} {r.text}"
return r.json()
async def reject_manual_payment_request(
client: AsyncClient, *, super_user_headers: dict, request_id: str,
) -> dict[str, Any]:
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{request_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 200, (
f"reject_manual_payment_request failed: {r.status_code} {r.text}"
)
return r.json()

View file

@ -0,0 +1,170 @@
"""Admin chart-of-accounts endpoint — POST /api/v1/admin/accounts.
Covers the endpoint wired into the UI's "Add Account" dialog:
- Writes an Open directive to accounts/chart.beancount via Fava /api/source,
*unconstrained* by currency (the directive needs no currency list), with
provenance + description metadata (escaped for Beancount).
- Mirrors the account into libra's DB (synced_to_libra_db).
- Rejects duplicates with 409, malformed names with 400, and non-super-users
with 403.
The harness ledger is the split layout (root includes accounts/chart.beancount)
so the endpoint's hardcoded target_file resolves — see conftest.CHART_SEED.
"""
import re
from pathlib import Path
from uuid import uuid4
import pytest
from .helpers import add_chart_account
def _chart_text(fava_ledger_path: Path) -> str:
return (fava_ledger_path.parent / "accounts" / "chart.beancount").read_text()
def _unique(prefix: str = "Expenses:Test") -> str:
# Capitalized leaf (valid Beancount component) unique per call so the
# session-scoped ledger doesn't collide across tests.
return f"{prefix}:T{uuid4().hex[:8].upper()}"
@pytest.mark.anyio
async def test_add_chart_account_writes_unconstrained_open_with_escaped_meta(
client, super_user_headers, fava_ledger_path,
):
"""Happy path: 201, the Open directive carries no currency constraint, the
description metadata is escaped, and the account is synced into libra's DB."""
name = _unique()
r = await add_chart_account(
client,
super_user_headers=super_user_headers,
name=name,
description='has a "quote" and ok',
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
assert body["account_name"] == name
assert body["synced_to_libra_db"] is True
chart = _chart_text(fava_ledger_path)
# Open present and UNCONSTRAINED: the account name is followed directly by
# end-of-line, not " EUR, SATS, USD".
assert re.search(rf"^\d{{4}}-\d{{2}}-\d{{2}} open {re.escape(name)}$", chart, re.MULTILINE), (
f"expected an unconstrained Open for {name}, chart was:\n{chart}"
)
# Description metadata is escaped so the quote can't break the ledger.
assert r'description: "has a \"quote\" and ok"' in chart
assert 'source: "admin-ui"' in chart
@pytest.mark.anyio
async def test_add_chart_account_with_explicit_currencies_constrains_open(
client, super_user_headers, fava_ledger_path,
):
"""API callers may still pass an explicit currency constraint (the UI never
does). When provided, it lands on the Open directive."""
name = _unique()
r = await client.post(
"/libra/api/v1/admin/accounts",
headers=super_user_headers,
json={"name": name, "currencies": ["EUR", "SATS"]},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
chart = _chart_text(fava_ledger_path)
assert re.search(rf"open {re.escape(name)} EUR, SATS$", chart, re.MULTILINE), (
f"expected a currency-constrained Open for {name}, chart was:\n{chart}"
)
@pytest.mark.anyio
async def test_add_chart_account_duplicate_returns_409(
client, super_user_headers,
):
"""Adding the same account twice: first 201, second 409 (not a false success)."""
name = _unique()
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"first add: {first.status_code} {first.text}"
second = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert second.status_code == 409, f"expected 409, got {second.status_code}: {second.text}"
assert "already exists" in second.json().get("detail", "").lower()
@pytest.mark.anyio
async def test_add_chart_account_recovers_ledger_only_account(
client, super_user_headers,
):
"""An account present in the ledger but absent from libra's DB (prior sync
failure / out-of-band edit) is recovered (synced), not 409'd — otherwise it
would be permanently un-grantable with no path back.
Reproduce the ledger-only state by creating normally (so Fava parses the
Open) then deleting only the libra-DB row appending to the ledger file
directly would race Fava's parse cache."""
from ..crud import db # the same singleton the app uses
name = _unique("Expenses:Recover")
first = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert first.status_code == 201, f"setup create failed: {first.status_code} {first.text}"
await db.execute("DELETE FROM accounts WHERE name = :name", {"name": name})
r = await add_chart_account(client, super_user_headers=super_user_headers, name=name)
assert r.status_code == 201, f"expected 201 recovery, got {r.status_code}: {r.text}"
body = r.json()
assert body.get("already_existed") is True, body
assert body["synced_to_libra_db"] is True, body
@pytest.mark.anyio
async def test_add_chart_account_invalid_prefix_returns_400(
client, super_user_headers, fava_ledger_path,
):
"""A root outside the five valid types is rejected and never written."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name="Foo:Bar")
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
@pytest.mark.parametrize(
"bad_name",
[
"Expenses:Foo Bar", # space
"Expenses:foo", # lowercase sub-component start
"Expenses:Foo!", # punctuation
"Expenses:", # no sub-account
"Expenses:Foo::Bar", # empty component
],
)
async def test_add_chart_account_invalid_characters_returns_400(
client, super_user_headers, fava_ledger_path, bad_name,
):
"""Malformed account names are rejected server-side (the UI guard can be
bypassed via the API) and never reach the ledger."""
before = _chart_text(fava_ledger_path)
r = await add_chart_account(client, super_user_headers=super_user_headers, name=bad_name)
assert r.status_code == 400, f"expected 400 for {bad_name!r}, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "rejected account must not be written"
@pytest.mark.anyio
async def test_add_chart_account_requires_super_user(
client, configured_user, fava_ledger_path,
):
"""A regular user's wallet admin-key passes require_admin_key but fails the
super-user identity check 403, nothing written."""
_user, wallet = configured_user
name = _unique()
before = _chart_text(fava_ledger_path)
r = await client.post(
"/libra/api/v1/admin/accounts",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
json={"name": name},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert _chart_text(fava_ledger_path) == before, "unauthorized add must not be written"

452
tests/test_balances_api.py Normal file
View file

@ -0,0 +1,452 @@
"""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}"
)

View file

@ -0,0 +1,219 @@
"""Admin-side journal entry endpoints — receivable and revenue.
- `POST /libra/api/v1/entries/receivable` admin records that a user owes
libra. Lands as a pending (`!`) entry, balance untouched until approve.
- `POST /libra/api/v1/entries/revenue` admin records that libra received
a payment unrelated to any user. Lands as a cleared (`*`) entry, no
approval needed.
Auth gate covered too: a regular user's wallet admin-key passes
`require_admin_key` but fails the super-user identity check in libra's own
`require_super_user`, so the endpoint returns 403.
"""
from uuid import uuid4
import pytest
from .helpers import (
get_balance,
list_user_entries,
post_receivable,
post_revenue,
)
@pytest.mark.anyio
async def test_admin_records_receivable_lands_cleared(
client, super_user_headers, configured_user, standard_accounts,
):
"""Admin posts a receivable for a user — the Beancount entry is written
with the cleared `*` flag immediately (not pending). The user's balance
reflects the debt without an approve step.
Note: `JournalEntry.flag` in the API response is misleading it's a
leftover of the legacy model and reports PENDING, but the entry in
Beancount is written as `*`. The on-disk reality is what affects the
balance, so that's what we assert.
"""
user, wallet = configured_user
response = await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="200.00",
currency="EUR",
description=f"December rent share {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
assert response.get("id"), f"expected id in response, got {response}"
# Force a fresh Fava read before checking balance — Fava lazily reloads
# the .beancount file and a balance call right after add_entry can hit
# a stale view.
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")
assert eur is not None, f"expected EUR in fiat_balances, got {balance}"
assert float(eur) == pytest.approx(200.0), (
f"expected +200 EUR (user-owes-libra) after receivable, got {eur}"
)
@pytest.mark.anyio
async def test_receivable_visible_in_target_users_journal(
client, super_user_headers, configured_user, standard_accounts,
):
"""The receivable shows up in the *debtor* user's journal listing
(not just in the admin view)."""
user, wallet = configured_user
tag = uuid4().hex[:6]
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount="75.00",
currency="EUR",
description=f"Workshop fee {tag}",
revenue_account=standard_accounts["revenue_fees"]["name"],
)
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
descriptions = [e.get("description") or "" for e in listing.get("entries", [])]
assert any(tag in d for d in descriptions), (
f"receivable missing from debtor's journal: {descriptions}"
)
@pytest.mark.anyio
async def test_admin_records_revenue_clears_immediately(
client, super_user_headers, standard_accounts,
):
"""Revenue (libra received money, no user debt) is cleared on creation —
no admin approval step."""
response = await post_revenue(
client,
super_user_headers=super_user_headers,
amount="500.00",
currency="EUR",
description=f"Workshop fees collected {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_fees"]["name"],
payment_method_account="Assets:Cash",
)
assert response.get("id"), f"expected id in response, got {response}"
# Cleared on creation — flag is `*`, no approve_entry call needed.
@pytest.mark.anyio
async def test_non_super_user_cannot_post_receivable(
client, configured_user, standard_accounts,
):
"""A regular user's wallet admin key passes `require_admin_key` but
fails libra's super-user identity check. Returns 403."""
user, wallet = configured_user
admin_key_headers = {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=admin_key_headers,
json={
"user_id": user.id,
"amount": "10.00",
"currency": "EUR",
"description": "Should be denied",
"revenue_account": standard_accounts["revenue_rent"]["name"],
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower(), (
f"expected super-user error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_non_super_user_cannot_post_revenue(
client, configured_user, standard_accounts,
):
"""Same super-user gate covers the revenue endpoint."""
_, wallet = configured_user
admin_key_headers = {"X-Api-Key": wallet.adminkey, "Content-type": "application/json"}
r = await client.post(
"/libra/api/v1/entries/revenue",
headers=admin_key_headers,
json={
"amount": "10.00",
"currency": "EUR",
"description": "Should be denied",
"revenue_account": standard_accounts["revenue_fees"]["name"],
"payment_method_account": "Assets:Cash",
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
@pytest.mark.anyio
async def test_receivable_unknown_revenue_account_returns_404(
client, super_user_headers, configured_user,
):
"""An admin posting against a non-existent revenue account gets 404."""
user, _ = configured_user
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "10.00",
"currency": "EUR",
"description": "Bad account",
"revenue_account": f"Income:Test:DoesNotExist-{uuid4().hex[:6]}",
},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()
@pytest.mark.anyio
async def test_receivable_unknown_currency_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""Currency validation hits before account lookups."""
user, _ = configured_user
r = await client.post(
"/libra/api/v1/entries/receivable",
headers=super_user_headers,
json={
"user_id": user.id,
"amount": "10.00",
"currency": "XYZ",
"description": "Bogus currency",
"revenue_account": standard_accounts["revenue_rent"]["name"],
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "currency" in r.text.lower() or "xyz" in r.text.lower()
@pytest.mark.anyio
async def test_revenue_unknown_payment_account_returns_404(
client, super_user_headers, standard_accounts,
):
"""Revenue endpoint validates BOTH accounts; the payment-method one too."""
r = await client.post(
"/libra/api/v1/entries/revenue",
headers=super_user_headers,
json={
"amount": "10.00",
"currency": "EUR",
"description": "Bad payment account",
"revenue_account": standard_accounts["revenue_fees"]["name"],
"payment_method_account": f"Assets:DoesNotExist-{uuid4().hex[:6]}",
},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()

View file

@ -0,0 +1,211 @@
"""User-side expense submission flow — `POST /libra/api/v1/entries/expense`.
Covers:
- Submission lands as a pending entry, visible to the user, doesn't move
the cleared-only balance.
- Cross-user isolation user B can't see user A's entries.
- Permission gating, currency validation, missing user-wallet setup.
- Multiple submissions accumulate in the user journal listing.
Settlement, approval, and balance-after-approval are exercised in
`test_smoke.py` (one canonical path) and `test_balances_api.py` (the mixed
income+expense display scenario the user named).
"""
from uuid import uuid4
import pytest
from .helpers import (
create_account,
get_balance,
list_user_entries,
post_expense,
)
@pytest.mark.anyio
async def test_expense_creates_pending_entry_visible_in_user_journal(
client, configured_user, standard_accounts,
):
"""Submitting an expense creates a pending (`!`) entry the user can see
immediately. The cleared-only balance query is unchanged because pending
entries are excluded."""
_, wallet = configured_user
response = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="25.00",
currency="EUR",
description="Test groceries",
expense_account=standard_accounts["expense_food"]["name"],
)
assert response.get("id"), f"expected id in response, got {response}"
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
assert any(
"Test groceries" in (e.get("description") or "") for e in entries
), f"submitted expense missing from /entries/user: {entries}"
bal = await get_balance(client, wallet_inkey=wallet.inkey)
assert not bal.get("fiat_balances"), (
f"pending entry should not affect cleared balance, got {bal}"
)
@pytest.mark.anyio
async def test_user_cannot_see_other_users_entries(
client, configured_user, configured_user_b, standard_accounts,
):
"""User A submits an expense; user B's `/entries/user` listing is
scoped to B and never references A's user-id account fragment."""
user_a, wallet_a = configured_user
_, wallet_b = configured_user_b
await post_expense(
client,
wallet_inkey=wallet_a.inkey,
user_wallet_id=wallet_a.id,
amount="40.00",
currency="EUR",
description=f"A-private-{uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
listing_b = await list_user_entries(client, wallet_inkey=wallet_b.inkey)
a_short = user_a.id[:8]
for entry in listing_b.get("entries", []):
for posting in entry.get("postings", []):
assert a_short not in posting.get("account", ""), (
f"user B's listing leaked user A's account: {posting}"
)
@pytest.mark.anyio
async def test_expense_without_permission_returns_403(
client, super_user_headers, configured_user,
):
"""Submitting to an expense account the user has no `submit_expense`
permission on returns 403 with a permission-error detail."""
_, wallet = configured_user
# Fresh expense account that no permission was granted on.
new_account = await create_account(
client,
super_user_headers=super_user_headers,
name=f"Expenses:Test:Unguarded-{uuid4().hex[:6]}",
account_type="expense",
)
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Should be denied",
"amount": "10.00",
"currency": "EUR",
"expense_account": new_account["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "permission" in r.text.lower(), (
f"expected permission error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_expense_with_unknown_currency_returns_400(
client, configured_user, standard_accounts,
):
"""An unsupported currency is rejected with 400 before any Fava call."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Unknown currency",
"amount": "10.00",
"currency": "XYZ",
"expense_account": standard_accounts["expense_food"]["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "currency" in r.text.lower(), (
f"expected currency error message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_expense_without_user_wallet_configured_returns_400(
client, libra_user, libra_wallet, standard_accounts, # noqa: ARG001 (libra_wallet ensures session-level setup)
):
"""A user whose own libra wallet isn't configured can't submit expenses.
`libra_user` (vs `configured_user`) skips the `PUT /user/wallet` step
on purpose so the precondition fires.
"""
_, wallet = libra_user
r = await client.post(
"/libra/api/v1/entries/expense",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={
"description": "Missing user wallet setup",
"amount": "10.00",
"currency": "EUR",
"expense_account": standard_accounts["expense_food"]["name"],
"user_wallet": wallet.id,
"is_equity": False,
},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "wallet" in r.text.lower(), (
f"expected wallet-config error, got {r.text!r}"
)
@pytest.mark.anyio
async def test_multiple_expenses_accumulate_in_user_journal(
client, configured_user, standard_accounts,
):
"""Each submission shows up in `/entries/user`; the listing's `total`
grows by exactly the number of submissions."""
_, wallet = configured_user
initial = await list_user_entries(client, wallet_inkey=wallet.inkey)
initial_total = initial.get("total", 0)
tag = uuid4().hex[:6]
descriptions = [f"Coffee-{tag}", f"Bread-{tag}", f"Vegetables-{tag}"]
for description in descriptions:
await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="7.50",
currency="EUR",
description=description,
expense_account=standard_accounts["expense_food"]["name"],
)
final = await list_user_entries(client, wallet_inkey=wallet.inkey)
final_total = final.get("total", 0)
assert final_total - initial_total == len(descriptions), (
f"expected total to grow by {len(descriptions)}, "
f"went from {initial_total} to {final_total}"
)
# Libra appends " (<amount> <currency>)" to entry descriptions, so check
# substring rather than exact match.
final_descs = [e.get("description") or "" for e in final.get("entries", [])]
for description in descriptions:
assert any(description in d for d in final_descs), (
f"missing {description} from journal listing: {final_descs}"
)

View file

@ -0,0 +1,168 @@
"""Entry identity resolution — the canonical id must survive a user reference.
Regression coverage for the production bug where a pending income entry
created with a `reference` (e.g. an invoice number like "42-144") could
not be approved: the admin UI's pending list resolved the entry id by
parsing links for a `libra-` prefix, but reference-bearing entries carry
typed links (`inc-/exp-/rcv-{id}`) plus the reference as its own link
no `libra-` link. The id surfaced as the literal string "unknown" and
`POST /entries/unknown/approve` 404'd.
The fix makes the `entry-id` transaction metadata the single source of
truth (list, approve, and reject endpoints), with link parsing kept only
for pre-metadata ledger history. These tests pin that contract:
- pending list returns the real id for reference-bearing entries
- approve/reject resolve that id end-to-end
- the user reference round-trips as `reference`, never as a system link
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
list_pending_entries,
list_user_entries,
post_expense,
post_income,
reject_entry,
)
@pytest.mark.anyio
async def test_pending_income_with_reference_resolves_real_id(
client, super_user_headers, configured_user, standard_accounts,
):
"""The production repro: income + reference must list with its real
id (not 'unknown') and approve successfully."""
_, wallet = configured_user
marker = f"Membership dues {uuid4().hex[:6]}"
posted = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="700.00", currency="EUR",
description=marker,
revenue_account=standard_accounts["revenue_rent"]["name"],
reference="42-144",
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None, f"income entry not in pending list: {pending}"
assert entry["id"] == posted["id"], (
f"pending list must surface the canonical entry id, "
f"got {entry['id']!r} (expected {posted['id']!r})"
)
assert entry["id"] != "unknown"
# The id from the listing must drive approval end-to-end.
result = await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]
@pytest.mark.anyio
async def test_pending_expense_with_reference_resolves_real_id_and_rejects(
client, super_user_headers, configured_user, standard_accounts,
):
"""Same contract on the expense path, exercised through reject."""
_, wallet = configured_user
marker = f"Receipted groceries {uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="36.93", currency="EUR",
description=marker,
expense_account=standard_accounts["expense_food"]["name"],
reference="RECEIPT/2026-06-12",
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None, f"expense entry not in pending list: {pending}"
assert entry["id"] == posted["id"]
result = await reject_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]
@pytest.mark.anyio
async def test_reference_round_trips_in_user_journal(
client, configured_user, standard_accounts,
):
"""The user journal must report the user's reference, not a system
link (typed inc-/exp- links used to leak into the reference field)."""
_, wallet = configured_user
marker = f"Referenced expense {uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="12.00", currency="EUR",
description=marker,
expense_account=standard_accounts["expense_food"]["name"],
reference="INV-7731",
)
assert posted.get("reference") == "INV-7731"
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entry = next(
(
e for e in listing.get("entries", [])
if marker in (e.get("description") or "")
),
None,
)
assert entry is not None
assert entry["id"] == posted["id"]
assert entry.get("reference") == "INV-7731", (
f"reference field must carry the user's reference, "
f"got {entry.get('reference')!r}"
)
@pytest.mark.anyio
async def test_entry_without_reference_still_resolves(
client, super_user_headers, configured_user, standard_accounts,
):
"""No-reference entries keep working (the case that always worked)."""
_, wallet = configured_user
marker = f"Plain income {uuid4().hex[:6]}"
posted = await post_income(
client,
wallet_inkey=wallet.inkey,
amount="55.00", currency="EUR",
description=marker,
revenue_account=standard_accounts["revenue_rent"]["name"],
)
pending = await list_pending_entries(
client, super_user_headers=super_user_headers,
)
entry = next(
(e for e in pending if marker in (e.get("description") or "")), None,
)
assert entry is not None
assert entry["id"] == posted["id"]
result = await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry["id"],
)
assert result.get("entry_id") == posted["id"]

205
tests/test_lightning_api.py Normal file
View file

@ -0,0 +1,205 @@
"""Lightning payment flow — `POST /generate-payment-invoice` and
`POST /record-payment`.
- User has a balance owed to libra user generates an invoice on the libra
wallet user pays it `/record-payment` records the settlement entry.
## Coverage status
This file covers auth gates and error paths that don't require an active
Lightning backend. Tests that actually need invoice generation are skipped
because:
- The default `VoidWallet` 500s on any invoice operation.
- Switching to `FakeWallet` (via `settings.lnbits_backend_wallet_class`)
DOES enable invoice generation, but the LifespanManager teardown then
hangs indefinitely under anyio's TestRunner — some Lightning-side
background task doesn't unwind cleanly. Investigation deferred; the
auth gates + 404/400 error paths are what we can lock in for now.
The skipped tests carry full implementations so flipping them back on is
a one-line change once the teardown issue is resolved (or once we move to
a subprocess-based runner for the LN file).
"""
from uuid import uuid4
import pytest
from .helpers import (
list_user_entries,
post_receivable,
)
NEEDS_LIGHTNING_BACKEND = pytest.mark.skip(
reason="Tracked by libra/issues/40 — VoidWallet 500s, FakeWallet hangs the "
"LifespanManager teardown under anyio's TestRunner. Flip when resolved."
)
async def _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
amount="100.00",
):
"""Helper: create + (auto-cleared) receivable so the user has a balance
owed to libra. Returns the (user, wallet) pair."""
user, wallet = configured_user
await post_receivable(
client,
super_user_headers=super_user_headers,
user_id=user.id,
amount=amount, currency="EUR",
description=f"Setup debt {uuid4().hex[:6]}",
revenue_account=standard_accounts["revenue_rent"]["name"],
)
# Force a Fava reload before downstream BQL balance reads (see #37).
await list_user_entries(client, wallet_inkey=wallet.inkey)
return user, wallet
# ---------------------------------------------------------------------------
# /generate-payment-invoice
# ---------------------------------------------------------------------------
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_user_can_generate_invoice_for_own_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""User with a receivable generates an invoice on the libra wallet.
Response carries the bolt11 string and the libra wallet's inkey for
the client to poll payment status."""
_, wallet = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"amount": 50_000}, # 50k sats partial settlement
)
assert r.status_code == 200, f"generate-invoice: {r.status_code} {r.text}"
payload = r.json()
assert payload.get("payment_hash"), f"missing payment_hash: {payload}"
assert payload.get("payment_request"), f"missing bolt11 payment_request: {payload}"
assert payload.get("amount") == 50_000
assert payload.get("check_wallet_key"), f"missing check_wallet_key: {payload}"
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_super_user_can_generate_invoice_for_another_user(
client, super_user_headers, libra_wallet, configured_user, standard_accounts,
):
"""Admin generating an invoice on behalf of a user — uses the libra
wallet's admin key + body `user_id`. The endpoint actually requires
`wallet.wallet.user == super_user` (which is the libra wallet owner).
Generate-invoice is `require_invoice_key`-gated so we pass the libra
wallet's invoice key, and the user_id field opts into "for that user".
"""
user, _ = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": libra_wallet.inkey, "Content-type": "application/json"},
json={"amount": 30_000, "user_id": user.id},
)
assert r.status_code == 200, f"admin generate-invoice: {r.status_code} {r.text}"
assert r.json().get("payment_request"), "admin-generated invoice missing bolt11"
@pytest.mark.anyio
async def test_non_super_user_cannot_generate_invoice_for_another_user(
client, super_user_headers, configured_user, configured_user_b,
standard_accounts,
):
"""A regular user cannot pass `user_id` and have libra generate an
invoice on someone else's behalf — 403."""
user_a, _ = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
_, wallet_b = configured_user_b
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet_b.inkey, "Content-type": "application/json"},
json={"amount": 10_000, "user_id": user_a.id},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_generate_invoice_without_auth_returns_401(client):
"""Invoice-key auth required — no header → 401."""
r = await client.post(
"/libra/api/v1/generate-payment-invoice",
json={"amount": 10_000},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
# ---------------------------------------------------------------------------
# /record-payment
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_record_payment_unknown_hash_returns_404(
client, configured_user,
):
"""Recording a payment hash that doesn't correspond to a real payment
in LNbits returns 404."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"payment_hash": "0" * 64},
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "payment not found" in r.text.lower() or "payment" in r.text.lower()
@NEEDS_LIGHTNING_BACKEND
@pytest.mark.anyio
async def test_record_payment_pending_invoice_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""A freshly-generated invoice that hasn't been paid yet is pending —
`/record-payment` must reject it with 400 rather than silently
recording a non-existent settlement."""
_, wallet = await _setup_receivable_balance(
client, super_user_headers, configured_user, standard_accounts,
)
# Generate an invoice on the libra wallet.
gen = await client.post(
"/libra/api/v1/generate-payment-invoice",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"amount": 15_000},
)
assert gen.status_code == 200
payment_hash = gen.json()["payment_hash"]
# Try to record it before any payment lands.
r = await client.post(
"/libra/api/v1/record-payment",
headers={"X-Api-Key": wallet.inkey, "Content-type": "application/json"},
json={"payment_hash": payment_hash},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "not yet settled" in r.text.lower() or "pending" in r.text.lower(), (
f"expected pending/settled message, got {r.text!r}"
)
@pytest.mark.anyio
async def test_record_payment_without_auth_returns_401(client):
r = await client.post(
"/libra/api/v1/record-payment",
json={"payment_hash": "abc"},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"

View file

@ -0,0 +1,307 @@
"""Manual payment request flow — user asks for libra to pay them via a
non-Lightning route (cash, bank, etc.); admin approves or rejects.
Endpoints:
- `POST /libra/api/v1/manual-payment-request` (invoice key, user)
- `GET /libra/api/v1/manual-payment-requests` (invoice key, own only)
- `GET /libra/api/v1/manual-payment-requests/all` (super user, all)
- `POST /libra/api/v1/manual-payment-requests/{id}/approve` (super user)
- `POST /libra/api/v1/manual-payment-requests/{id}/reject` (super user)
The amount in the request body is in **satoshis** (no fiat conversion at this
endpoint `CreateManualPaymentRequest` has `amount: int`).
Approve creates a Beancount payment entry:
DR Liabilities:Payable:User-{id} (zeroes libra's debt to the user)
CR Assets:Bitcoin:Lightning (cash leaves libra)
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_manual_payment_request,
reject_manual_payment_request,
submit_manual_payment_request,
)
# ---------------------------------------------------------------------------
# User-side submission
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_user_can_submit_manual_payment_request(
client, configured_user,
):
"""Submission returns 200 with a pending request and the user's id."""
user, wallet = configured_user
desc = f"Coffee reimbursement {uuid4().hex[:6]}"
result = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=50_000,
description=desc,
)
assert result.get("id"), f"missing id: {result}"
assert result.get("user_id") == user.id
assert result.get("amount") == 50_000
assert result.get("description") == desc
assert result.get("status") == "pending"
@pytest.mark.anyio
async def test_user_lists_own_manual_payment_requests(
client, configured_user,
):
"""The user-side listing returns the requests this user submitted."""
_, wallet = configured_user
tag = uuid4().hex[:6]
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=12_000,
description=f"list-test {tag}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests",
headers={"X-Api-Key": wallet.inkey},
)
assert r.status_code == 200, f"list: {r.status_code} {r.text}"
ids = [req.get("id") for req in r.json()]
assert submitted["id"] in ids, f"submitted request missing from listing: {ids}"
@pytest.mark.anyio
async def test_user_cannot_see_another_users_manual_payment_requests(
client, configured_user, configured_user_b,
):
"""User-side listing is scoped to the calling user, not all requests."""
user_a, wallet_a = configured_user
_, wallet_b = configured_user_b
submitted_a = await submit_manual_payment_request(
client,
wallet_inkey=wallet_a.inkey,
amount_sats=8_000,
description=f"A-private {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests",
headers={"X-Api-Key": wallet_b.inkey},
)
assert r.status_code == 200
user_ids = {req.get("user_id") for req in r.json()}
ids = [req.get("id") for req in r.json()]
assert submitted_a["id"] not in ids, (
f"user B saw user A's request: {submitted_a['id']} in {ids}"
)
assert user_a.id not in user_ids, (
f"user B's listing contained user A's id: {user_ids}"
)
# ---------------------------------------------------------------------------
# Admin listing
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_can_list_all_manual_payment_requests(
client, super_user_headers, configured_user, configured_user_b,
):
"""The admin listing returns requests from any user."""
_, wallet_a = configured_user
_, wallet_b = configured_user_b
a_req = await submit_manual_payment_request(
client,
wallet_inkey=wallet_a.inkey,
amount_sats=10_000,
description=f"A {uuid4().hex[:6]}",
)
b_req = await submit_manual_payment_request(
client,
wallet_inkey=wallet_b.inkey,
amount_sats=20_000,
description=f"B {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests/all",
headers=super_user_headers,
)
assert r.status_code == 200, f"admin list: {r.status_code} {r.text}"
ids = [req.get("id") for req in r.json()]
assert a_req["id"] in ids and b_req["id"] in ids, (
f"admin list missing entries: ids={ids}"
)
@pytest.mark.anyio
async def test_admin_listing_status_filter(
client, super_user_headers, configured_user,
):
"""`?status=pending` returns only the pending requests."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=5_000,
description=f"pending-filter {uuid4().hex[:6]}",
)
r = await client.get(
"/libra/api/v1/manual-payment-requests/all?status=pending",
headers=super_user_headers,
)
assert r.status_code == 200, f"filtered list: {r.status_code} {r.text}"
statuses = {req.get("status") for req in r.json()}
assert statuses == {"pending"}, f"non-pending rows in filtered list: {statuses}"
assert submitted["id"] in [req.get("id") for req in r.json()]
@pytest.mark.anyio
async def test_non_super_user_cannot_list_all_requests(
client, configured_user,
):
"""Wallet admin-key of a non-super user fails the super-user check."""
_, wallet = configured_user
r = await client.get(
"/libra/api/v1/manual-payment-requests/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()
# ---------------------------------------------------------------------------
# Approve / reject
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_admin_can_reject_manual_payment_request(
client, super_user_headers, configured_user,
):
"""Reject flips status to 'rejected' and doesn't touch Beancount."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=3_500,
description=f"reject me {uuid4().hex[:6]}",
)
result = await reject_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
assert result.get("status") == "rejected"
@pytest.mark.anyio
async def test_rejecting_already_rejected_returns_400(
client, super_user_headers, configured_user,
):
"""The endpoint guards against double-decisions."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=4_000,
description=f"double reject {uuid4().hex[:6]}",
)
await reject_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "reject" in r.text.lower()
@pytest.mark.anyio
async def test_approve_unknown_request_returns_404(
client, super_user_headers,
):
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{uuid4().hex[:16]}/approve",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_approve(
client, configured_user,
):
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=2_000,
description=f"no approve for you {uuid4().hex[:6]}",
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_admin_can_approve_manual_payment_request(
client, super_user_headers, configured_user, standard_accounts,
# noqa: ARG001 (standard_accounts ensures Assets:Bitcoin:Lightning exists)
):
"""Approve creates a Beancount payment entry and flips status to
'approved'. Requires `Assets:Bitcoin:Lightning` to exist in libra's
local DB (provided by the `standard_accounts` fixture)."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=6_000,
description=f"approve me {uuid4().hex[:6]}",
)
result = await approve_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
assert result.get("status") == "approved"
assert result.get("id") == submitted["id"]
@pytest.mark.anyio
async def test_approving_already_approved_returns_400(
client, super_user_headers, configured_user, standard_accounts,
):
"""Idempotency guard: second approve on the same request is rejected
explicitly rather than producing a duplicate Beancount entry."""
_, wallet = configured_user
submitted = await submit_manual_payment_request(
client,
wallet_inkey=wallet.inkey,
amount_sats=7_500,
description=f"approve once {uuid4().hex[:6]}",
)
await approve_manual_payment_request(
client, super_user_headers=super_user_headers, request_id=submitted["id"],
)
r = await client.post(
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "approve" in r.text.lower()

View file

@ -0,0 +1,294 @@
"""Balance assertion CRUD + reconciliation summary endpoints.
Endpoints:
- `POST /libra/api/v1/assertions` create + check
- `GET /libra/api/v1/assertions` list with filters
- `GET /libra/api/v1/assertions/{id}` fetch one
- `POST /libra/api/v1/assertions/{id}/check` re-check
- `DELETE /libra/api/v1/assertions/{id}` remove
All `require_super_user` (libra-level, wallet admin-key).
The create endpoint is hybrid: it posts a Beancount `balance` directive via
Fava (source of truth), persists the assertion metadata in libra's DB, and
re-checks immediately. On mismatch it returns 409 with the diff payload.
"""
from uuid import uuid4
import pytest
# Tests that try to actually create + check an assertion all hit issue #39:
# `format_balance` returns a Beancount source string but `fava.add_entry`
# expects a dict, so Fava 500s on every assertion-create call. The contract
# violation is on libra's side; mark these strict-xfail so they go green
# automatically once #39 lands and the format_balance return shape is fixed.
ASSERTION_CREATE_BROKEN = pytest.mark.xfail(
reason="libra/issues/39 — POST /assertions submits a Beancount source string "
"to Fava's JSON API and 500s. Drop this marker when the format_balance "
"return type is changed to a dict.",
strict=True,
)
# ---------------------------------------------------------------------------
# helpers (local — assertion endpoints don't have wrapper helpers yet)
# ---------------------------------------------------------------------------
async def _create_assertion(
client, *, super_user_headers, account_id, expected_sats,
tolerance_sats=0, fiat_currency=None, expected_fiat=None,
):
body = {
"account_id": account_id,
"expected_balance_sats": expected_sats,
"tolerance_sats": tolerance_sats,
}
if fiat_currency:
body["fiat_currency"] = fiat_currency
body["expected_balance_fiat"] = str(expected_fiat) if expected_fiat is not None else "0"
return await client.post(
"/libra/api/v1/assertions", headers=super_user_headers, json=body,
)
# ---------------------------------------------------------------------------
# tests
# ---------------------------------------------------------------------------
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_against_empty_account_passes(
client, super_user_headers, standard_accounts,
):
"""An asset account with no postings has a 0 balance — asserting 0
should pass and the resulting assertion has status='passed'."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
body = r.json()
assert body.get("status") == "passed", (
f"expected status='passed' for 0=0, got {body.get('status')} body={body}"
)
assert body.get("difference_sats", 0) == 0
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_with_wrong_balance_returns_409(
client, super_user_headers, standard_accounts,
):
"""When the actual balance doesn't match expected, the create endpoint
returns 409 Conflict with the diff payload Beancount validates it
server-side after the directive lands."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=999_999, # wildly wrong for empty account
)
assert r.status_code == 409, f"expected 409, got {r.status_code}: {r.text}"
# 409 body should expose the diff so a UI can render the gap.
detail = r.json().get("detail")
assert isinstance(detail, dict), f"expected structured detail, got {detail!r}"
assert detail.get("expected_sats") == 999_999
assert detail.get("actual_sats") == 0
assert detail.get("difference_sats") == 999_999 or detail.get("difference_sats") == -999_999
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_assertion_with_tolerance_accepts_small_diff(
client, super_user_headers, standard_accounts,
):
"""A tolerance of N sats lets actual-vs-expected diverge by ≤N."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=50,
tolerance_sats=100, # actual=0, expected=50, diff=50, tolerance=100 → passes
)
assert r.status_code == 200, f"expected 200 within tolerance, got {r.status_code}: {r.text}"
assert r.json().get("status") == "passed"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_list_assertions_returns_created(
client, super_user_headers, standard_accounts,
):
"""Newly created assertions show up in the list filtered by account."""
account_id = standard_accounts["assets_cash"]["id"]
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=account_id,
expected_sats=0,
)
assert create.status_code == 200
assertion_id = create.json()["id"]
r = await client.get(
f"/libra/api/v1/assertions?account_id={account_id}",
headers=super_user_headers,
)
assert r.status_code == 200, f"list assertions: {r.status_code} {r.text}"
ids = [a.get("id") for a in r.json()]
assert assertion_id in ids, f"created assertion {assertion_id} missing from list {ids}"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_get_assertion_by_id(
client, super_user_headers, standard_accounts,
):
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assert create.status_code == 200
assertion_id = create.json()["id"]
r = await client.get(
f"/libra/api/v1/assertions/{assertion_id}",
headers=super_user_headers,
)
assert r.status_code == 200, f"get assertion: {r.status_code} {r.text}"
assert r.json().get("id") == assertion_id
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_recheck_assertion_via_check_endpoint(
client, super_user_headers, standard_accounts,
):
"""`POST /assertions/{id}/check` re-evaluates and returns the updated
assertion record. Idempotent against a stable ledger state."""
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assertion_id = create.json()["id"]
r = await client.post(
f"/libra/api/v1/assertions/{assertion_id}/check",
headers=super_user_headers,
)
assert r.status_code == 200, f"recheck: {r.status_code} {r.text}"
assert r.json().get("status") == "passed"
@ASSERTION_CREATE_BROKEN
@pytest.mark.anyio
async def test_delete_assertion_removes_it(
client, super_user_headers, standard_accounts,
):
create = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=standard_accounts["assets_cash"]["id"],
expected_sats=0,
)
assertion_id = create.json()["id"]
r = await client.delete(
f"/libra/api/v1/assertions/{assertion_id}",
headers=super_user_headers,
)
assert r.status_code in (200, 204), f"delete: {r.status_code} {r.text}"
# Subsequent GET should 404.
r = await client.get(
f"/libra/api/v1/assertions/{assertion_id}",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404 after delete, got {r.status_code}"
@pytest.mark.anyio
async def test_assertion_unknown_account_returns_404(
client, super_user_headers,
):
"""Account-not-found check happens before any Beancount write."""
r = await _create_assertion(
client,
super_user_headers=super_user_headers,
account_id=f"nonexistent-{uuid4().hex[:6]}",
expected_sats=0,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_create_assertion(
client, configured_user, standard_accounts,
):
"""Wallet admin-key of a regular user fails the super-user identity
check 403."""
_, wallet = configured_user
r = await client.post(
"/libra/api/v1/assertions",
headers={"X-Api-Key": wallet.adminkey, "Content-type": "application/json"},
json={
"account_id": standard_accounts["assets_cash"]["id"],
"expected_balance_sats": 0,
},
)
assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text}"
assert "super" in r.text.lower()
@pytest.mark.anyio
async def test_list_assertions_invalid_status_returns_400(
client, super_user_headers,
):
"""Status filter is validated against the AssertionStatus enum."""
r = await client.get(
"/libra/api/v1/assertions?status=not_a_status",
headers=super_user_headers,
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "status" in r.text.lower()
@pytest.mark.anyio
async def test_reconciliation_summary_endpoint(client, super_user_headers):
"""`GET /reconciliation/summary` responds 200 and returns a structured
payload even when no assertions exist. Smoke-shape only exact counts
depend on ledger history.
Doesn't pre-create an assertion (#39 blocks that path); the summary
endpoint should still serve a default empty shape.
"""
r = await client.get(
"/libra/api/v1/reconciliation/summary",
headers=super_user_headers,
)
assert r.status_code == 200, f"reconciliation summary: {r.status_code} {r.text}"
payload = r.json()
assert isinstance(payload, dict), f"expected dict, got {type(payload)}"
@pytest.mark.anyio
async def test_daily_reconciliation_task_runs(
client, super_user_headers,
):
"""The daily-reconciliation task endpoint returns 200 even when no
assertions exist it's the entry point that ops cron hits."""
r = await client.post(
"/libra/api/v1/tasks/daily-reconciliation",
headers=super_user_headers,
)
assert r.status_code == 200, f"daily-reconciliation: {r.status_code} {r.text}"

View file

@ -0,0 +1,202 @@
"""Settings and per-user wallet endpoints, plus the auth gates around them.
Endpoints and their auth profiles:
- `GET /libra/api/v1/settings` any authenticated user.
- `PUT /libra/api/v1/settings` `check_super_user` (Bearer, super-user only).
- `GET /libra/api/v1/user/wallet` `check_user_exists` (any authed user).
- `PUT /libra/api/v1/user/wallet` `check_user_exists`.
- `GET /libra/api/v1/user-wallet/{user_id}` `require_super_user` (libra
super-user via wallet admin-key auth).
Two distinct super-user auth flows live here side by side:
- LNbits-level `check_super_user` Bearer token from username/password login.
- Libra-level `require_super_user` wallet admin-key of the super-user-owned
wallet.
Tests use the `super_user_bearer_headers` fixture for the first, the
`super_user_headers` fixture for the second, and `?usr=<user_id>` for
non-admin authed calls.
"""
from uuid import uuid4
import pytest
@pytest.mark.anyio
async def test_super_user_can_get_and_update_settings(
client, super_user_bearer_headers, libra_wallet, fava_process,
):
"""Super user round-trips through `GET /settings` → mutate → `PUT /settings`.
Verifies the Bearer-auth happy path and confirms `update_settings`
persists what we sent (modulo defaults libra fills in).
"""
r = await client.get(
"/libra/api/v1/settings", headers=super_user_bearer_headers,
)
assert r.status_code == 200, f"GET /settings: {r.status_code} {r.text}"
original = r.json()
assert original.get("libra_wallet_id") == libra_wallet.id, (
f"libra_wallet fixture should have configured wallet_id, got {original}"
)
new_timeout = 7.5
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={
"libra_wallet_id": libra_wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": "libra-test",
"fava_timeout": new_timeout,
},
)
assert r.status_code == 200, f"PUT /settings: {r.status_code} {r.text}"
assert float(r.json().get("fava_timeout", 0)) == pytest.approx(new_timeout)
# Reset to keep other tests' baseline intact.
await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={
"libra_wallet_id": libra_wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": "libra-test",
"fava_timeout": original.get("fava_timeout", 5.0),
},
)
@pytest.mark.anyio
async def test_put_settings_without_libra_wallet_id_returns_400(
client, super_user_bearer_headers,
):
"""The settings endpoint explicitly rejects updates with no wallet id.
This is the validation libra applies before any persistence so we don't
silently accept a settings row that breaks all entry endpoints.
"""
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={"fava_url": "http://example.test"},
)
assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}"
assert "wallet" in r.text.lower()
@pytest.mark.anyio
async def test_put_settings_without_auth_returns_401(client, libra_wallet):
"""No auth at all → LNbits's `check_admin` rejects with 401."""
r = await client.put(
"/libra/api/v1/settings",
json={"libra_wallet_id": libra_wallet.id},
)
assert r.status_code == 401, f"expected 401, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_regular_user_cannot_put_settings(
client, configured_user, libra_wallet,
):
"""A non-super user (regardless of auth method they try) cannot update
libra settings. Using `?usr=<id>` to mimic user-id login."""
user, _ = configured_user
r = await client.put(
f"/libra/api/v1/settings?usr={user.id}",
json={"libra_wallet_id": libra_wallet.id},
)
# `_check_account_exists` forbids user-id login for admin accounts and
# rejects regular users from `check_admin` paths — either 401 or 403
# is a valid no-access response here.
assert r.status_code in (401, 403), (
f"expected 401/403, got {r.status_code}: {r.text}"
)
@pytest.mark.anyio
async def test_regular_user_can_get_and_update_own_user_wallet(
client, libra_user, libra_wallet, # noqa: ARG001 (libra_wallet ensures session setup)
):
"""A regular user (no admin perm) can read and update their own
`user_wallet_id` via `?usr=<id>`."""
user, wallet = libra_user
r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}")
assert r.status_code == 200, f"GET /user/wallet: {r.status_code} {r.text}"
r = await client.put(
f"/libra/api/v1/user/wallet?usr={user.id}",
json={"user_wallet_id": wallet.id},
)
assert r.status_code == 200, f"PUT /user/wallet: {r.status_code} {r.text}"
r = await client.get(f"/libra/api/v1/user/wallet?usr={user.id}")
assert r.json().get("user_wallet_id") == wallet.id, (
f"GET after PUT should echo wallet id, got {r.json()}"
)
@pytest.mark.anyio
async def test_super_user_can_get_any_user_wallet(
client, super_user_headers, configured_user,
):
"""The `/user-wallet/{user_id}` endpoint (libra `require_super_user`,
wallet-admin-key auth) returns wallet info for any user."""
user, wallet = configured_user
r = await client.get(
f"/libra/api/v1/user-wallet/{user.id}", headers=super_user_headers,
)
assert r.status_code == 200, f"GET /user-wallet/{user.id}: {r.status_code} {r.text}"
payload = r.json()
assert payload.get("user_id") == user.id
assert payload.get("user_wallet_id") == wallet.id, (
f"expected user_wallet_id={wallet.id}, got {payload}"
)
@pytest.mark.anyio
async def test_regular_user_cannot_use_super_only_user_wallet_endpoint(
client, configured_user, configured_user_b,
):
"""User B can't see user A's wallet info via the super-only admin
endpoint, even with B's own wallet admin-key."""
user_a, _ = configured_user
_, wallet_b = configured_user_b
r = await client.get(
f"/libra/api/v1/user-wallet/{user_a.id}",
headers={"X-Api-Key": wallet_b.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()
@pytest.mark.anyio
async def test_unknown_currency_in_settings_does_not_corrupt(
client, super_user_bearer_headers, libra_wallet, fava_process,
):
"""Passing an unexpected field in the settings body shouldn't bring the
endpoint down pydantic should ignore extras and persist the rest.
A canary for "what if the UI sends a slightly-stale settings shape?"
"""
r = await client.put(
"/libra/api/v1/settings",
headers=super_user_bearer_headers,
json={
"libra_wallet_id": libra_wallet.id,
"fava_url": fava_process,
"fava_ledger_slug": "libra-test",
"some_unexpected_field_": str(uuid4()),
},
)
# Either 200 (extras dropped) or 422 (strict validation) — both are
# acceptable defensive behaviours; just don't 500.
assert r.status_code in (200, 422), (
f"unexpected field should be ignored or rejected cleanly, "
f"got {r.status_code}: {r.text}"
)

View 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}"

66
tests/test_smoke.py Normal file
View file

@ -0,0 +1,66 @@
"""Smoke test: validates the test harness end-to-end.
If this passes, the rest of the test files can be trusted to actually exercise
real code paths (Fava up, app up, Libra activated, FavaClient pointed at the
test instance, BQL round-trips working, libra wallet configured, user wallet
configured, account exists, permission granted).
If this fails, no point running anything else fix the harness first.
"""
import pytest
from .helpers import approve_entry, get_balance, post_expense
@pytest.mark.anyio
async def test_smoke_submit_approve_and_see_balance(
client, super_user_headers, configured_user, standard_accounts,
):
"""Full stack round-trip: user submits an expense, admin approves it,
balance reflects it.
Exercises: libra wallet config (session fixture), user wallet config
(configured_user fixture), permission grant (configured_user fixture),
Beancount entry construction, Fava add_entries HTTP call, pendingcleared
flag transition via the source-slice mutation path, BQL balance query
(which filters by flag = '*' so the approve step is load-bearing).
"""
_, wallet = configured_user
# User pays 50 EUR for groceries — entry posted with flag `!` (pending).
entry = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="50.00",
currency="EUR",
description="Smoke test expense",
expense_account=standard_accounts["expense_food"]["name"],
)
entry_id = entry.get("id")
assert entry_id, f"expense response missing id: {entry}"
# Pending entries are excluded from the cleared-only balance query —
# confirm balance is still zero at this point.
pending_balance = await get_balance(client, wallet_inkey=wallet.inkey)
pending_eur = pending_balance.get("fiat_balances", {}).get("EUR")
assert pending_eur in (None, 0, "0", "0.00"), (
f"pending expense should not affect cleared balance, got {pending_eur}"
)
# Admin approves the pending entry, flipping its flag from `!` to `*`.
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=entry_id,
)
# Balance now reflects the 50 EUR Libra owes the user.
# Sign convention (per get_user_balance_bql docstring): the API returns
# the balance from libra's perspective — negative on Liabilities:Payable
# means libra owes the user. So a 50 EUR expense surfaces as -50 EUR.
balance = await get_balance(client, wallet_inkey=wallet.inkey)
fiat = balance.get("fiat_balances", {})
eur = fiat.get("EUR")
assert eur is not None, f"expected EUR in fiat_balances after approve, got {balance}"
assert float(eur) == pytest.approx(-50.0), (
f"expected EUR balance of -50.00 (libra-owes-user) after approve, got {eur}"
)

572
tests/test_unit.py Normal file
View file

@ -0,0 +1,572 @@
"""Pure-function unit tests — no harness, no Fava, no LNbits app.
Covers `libra.beancount_format`, `libra.account_utils`, `libra.core.validation`.
These modules have no external dependencies (stdlib + pydantic for models), so
they run fast and don't need fixtures.
The libra package is importable under either `lnbits.extensions.libra.*`
(default lnbits layout) or `libra.*` (LNBITS_EXTENSIONS_PATH override). The
`_module` helper tries both, mirroring the runtime-path discipline already
established in `conftest.py`.
"""
import importlib
from datetime import date
from decimal import Decimal
import pytest
def _module(name: str):
"""Import a libra submodule under whichever path the active LNbits layout
uses (default `lnbits.extensions.libra` or bare `libra`)."""
for prefix in ("lnbits.extensions.libra", "libra"):
try:
return importlib.import_module(f"{prefix}.{name}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
bf = _module("beancount_format")
au = _module("account_utils")
val = _module("core.validation")
mdl = _module("models")
fc = _module("fava_client")
AccountType = mdl.AccountType
# ---------------------------------------------------------------------------
# fava_client._open_directive_exists — duplicate-account detection
# ---------------------------------------------------------------------------
def test_open_directive_exists_matches_real_directive():
src = "2020-01-01 open Expenses:Vehicle:Gas\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_matches_currency_constrained_and_metadata():
src = (
"2020-01-01 open Expenses:Vehicle:Gas EUR, SATS\n"
' added_by: "abc"\n'
)
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_matches_inline_comment_without_space():
# Valid Beancount: the account token ends at ';'. The old (?:\\s|$) boundary
# missed this → duplicate Open written → bean-check breaks.
src = "2020-01-01 open Expenses:Vehicle:Gas;legacy-import\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is True
def test_open_directive_exists_ignores_name_inside_description():
# The name appears only inside another account's description metadata.
src = (
"2020-01-01 open Expenses:Notes\n"
' description: "remember to open Expenses:Vehicle:Gas next month"\n'
)
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_ignores_comment_line():
src = "; TODO: open Expenses:Vehicle:Gas eventually\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_does_not_match_longer_sibling():
src = "2020-01-01 open Expenses:Vehicle:GasStation\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
def test_open_directive_exists_does_not_match_deeper_child():
src = "2020-01-01 open Expenses:Vehicle:Gas:Premium\n"
assert fc._open_directive_exists(src, "Expenses:Vehicle:Gas") is False
@pytest.mark.parametrize(
"line",
[
"2024/3/5 open Expenses:Vehicle:Gas", # slash date, single-digit M/D
"2020-1-1 open Expenses:Vehicle:Gas", # dash date, single-digit M/D
"2020-01-01 open Expenses:Vehicle:Gas", # multiple spaces
"2020-01-01\topen\tExpenses:Vehicle:Gas", # tab separators
"1970-01-01 open Expenses:Vehicle:Gas EUR", # currency-constrained
],
)
def test_open_directive_exists_matches_beancount_date_and_whitespace_variants(line):
# All of these are valid Beancount Open directives per lexer.l's DATE token
# and ignored inter-token whitespace; each must be detected as existing.
assert fc._open_directive_exists(line + "\n", "Expenses:Vehicle:Gas") is True
# ---------------------------------------------------------------------------
# beancount_format.sanitize_link
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("raw", "expected"),
[
("libra-abc123", "libra-abc123"),
("Invoice #123", "Invoice-123"),
("Test (pending)", "Test-pending"),
("a/b.c-d_e", "a/b.c-d_e"), # all permitted chars survive
("multiple spaces", "multiple-spaces"), # collapsed
("---leading-trailing---", "leading-trailing"),
("ascii_only", "ascii_only"),
],
)
def test_sanitize_link_strips_unsafe_chars(raw, expected):
assert bf.sanitize_link(raw) == expected
def test_sanitize_link_empty_string_stays_empty():
assert bf.sanitize_link("") == ""
def test_sanitize_link_unicode_replaced_with_hyphens():
# Non-ascii chars all collapse to single hyphens, stripped from edges.
result = bf.sanitize_link("café résumé")
assert all(ch in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/."
for ch in result), f"unsanitized chars in {result!r}"
assert not result.startswith("-")
assert not result.endswith("-")
# ---------------------------------------------------------------------------
# beancount_format.format_transaction
# ---------------------------------------------------------------------------
def test_format_transaction_minimum_shape():
entry = bf.format_transaction(
date_val=date(2026, 6, 6),
flag="*",
narration="hello",
postings=[{"account": "Assets:Cash", "amount": "10 EUR"}],
)
# Fava's required fields.
assert entry["t"] == "Transaction"
assert entry["date"] == "2026-06-06"
assert entry["flag"] == "*"
assert entry["narration"] == "hello"
assert entry["payee"] == "" # empty string, not None
assert entry["tags"] == []
assert entry["links"] == []
assert entry["meta"] == {}
assert entry["postings"] == [{"account": "Assets:Cash", "amount": "10 EUR"}]
def test_format_transaction_optional_fields_are_passed_through():
entry = bf.format_transaction(
date_val=date(2026, 6, 6),
flag="!",
narration="pending lunch",
postings=[{"account": "Expenses:Food", "amount": "8 EUR"}],
payee="Bistro Local",
tags=["expense-entry"],
links=["libra-abc123"],
meta={"user-id": "abc12345"},
)
assert entry["flag"] == "!"
assert entry["payee"] == "Bistro Local"
assert entry["tags"] == ["expense-entry"]
assert entry["links"] == ["libra-abc123"]
assert entry["meta"] == {"user-id": "abc12345"}
def test_format_transaction_does_not_share_mutable_defaults():
"""Regression guard: passing `tags=None` shouldn't return the same list
every call (the classic Python mutable-default-argument trap)."""
a = bf.format_transaction(date(2026, 1, 1), "*", "a", [{"account": "X", "amount": "1 EUR"}])
b = bf.format_transaction(date(2026, 1, 2), "*", "b", [{"account": "Y", "amount": "1 EUR"}])
a["tags"].append("touched-a")
assert b["tags"] == [], "tags from one entry leaked into another"
# ---------------------------------------------------------------------------
# beancount_format.generate_entry_id
# ---------------------------------------------------------------------------
def test_generate_entry_id_shape():
eid = bf.generate_entry_id()
assert len(eid) == 16
assert all(c in "0123456789abcdef" for c in eid), f"non-hex in {eid!r}"
def test_generate_entry_ids_are_unique():
ids = {bf.generate_entry_id() for _ in range(100)}
assert len(ids) == 100 # 16 hex chars = 64 bits; collisions in 100 are negligible
# ---------------------------------------------------------------------------
# Entry identity contract — every libra-authored entry formatter must write
# `entry-id` metadata (the canonical id) and keep the user reference as its
# own sanitized link, never fused with the id.
# ---------------------------------------------------------------------------
def test_format_expense_entry_identity_contract():
entry = bf.format_expense_entry(
user_id="abc12345",
expense_account="Expenses:Food",
user_account="Liabilities:Payable:User-abc12345",
amount_sats=50000,
description="Groceries",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("46.50"),
reference="Invoice #123",
entry_id="deadbeef00000001",
)
assert entry["meta"]["entry-id"] == "deadbeef00000001"
assert "exp-deadbeef00000001" in entry["links"]
assert "Invoice-123" in entry["links"] # sanitized, standalone
def test_format_receivable_entry_identity_contract():
entry = bf.format_receivable_entry(
user_id="abc12345",
revenue_account="Income:Accommodation",
receivable_account="Assets:Receivable:User-abc12345",
amount_sats=100000,
description="2-night stay",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("93.00"),
reference="BOOKING/42",
entry_id="deadbeef00000002",
)
assert entry["meta"]["entry-id"] == "deadbeef00000002"
assert "rcv-deadbeef00000002" in entry["links"]
assert "BOOKING/42" in entry["links"]
def test_format_income_entry_identity_contract():
"""The production-bug shape: income + reference like '42-144'."""
entry = bf.format_income_entry(
user_id="abc12345",
user_account="Assets:Receivable:User-abc12345",
revenue_account="Income:MemberDuesContributions",
amount_sats=1112490,
description="2 Memberships",
entry_date=date(2026, 6, 12),
fiat_currency="USD",
fiat_amount=Decimal("700.00"),
reference="42-144",
entry_id="deadbeef00000003",
)
assert entry["meta"]["entry-id"] == "deadbeef00000003"
assert "inc-deadbeef00000003" in entry["links"]
assert "42-144" in entry["links"]
def test_format_revenue_entry_identity_contract():
entry = bf.format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date(2026, 6, 12),
fiat_currency="EUR",
fiat_amount=Decimal("50.00"),
reference="Till receipt 9",
entry_id="deadbeef00000004",
)
assert entry["meta"]["entry-id"] == "deadbeef00000004"
assert "Till-receipt-9" in entry["links"] # sanitized
def test_format_revenue_entry_generates_entry_id_when_absent():
entry = bf.format_revenue_entry(
payment_account="Assets:Cash",
revenue_account="Income:Sales",
amount_sats=100000,
description="Product sale",
entry_date=date(2026, 6, 12),
)
eid = entry["meta"]["entry-id"]
assert len(eid) == 16 and all(c in "0123456789abcdef" for c in eid)
# ---------------------------------------------------------------------------
# account_utils.format_hierarchical_account_name
# ---------------------------------------------------------------------------
def test_format_hierarchical_simple_asset():
assert au.format_hierarchical_account_name(AccountType.ASSET, "Cash") == "Assets:Cash"
def test_format_hierarchical_user_specific_uses_8_char_prefix():
full_user_id = "af983632aabbccddeeff00112233445566"
name = au.format_hierarchical_account_name(
AccountType.ASSET, "Accounts Receivable", user_id=full_user_id,
)
assert name == "Assets:Receivable:User-af983632" # 8-char prefix, "Accounts " stripped
def test_format_hierarchical_ampersand_expands_to_colon():
"""`Food & Supplies` is a legacy display form; it becomes a hierarchy."""
name = au.format_hierarchical_account_name(AccountType.EXPENSE, "Food & Supplies")
assert name == "Expenses:Food:Supplies"
def test_format_hierarchical_revenue_uses_income_root():
"""Beancount uses `Income`, not `Revenue` — the mapping is in
`ACCOUNT_TYPE_ROOTS`."""
name = au.format_hierarchical_account_name(AccountType.REVENUE, "Accommodation")
assert name == "Income:Accommodation"
# ---------------------------------------------------------------------------
# account_utils.parse_legacy_account_name
# ---------------------------------------------------------------------------
def test_parse_legacy_with_user_suffix():
assert au.parse_legacy_account_name("Accounts Receivable - af983632") == (
"Accounts Receivable", "af983632",
)
def test_parse_legacy_without_user_suffix():
assert au.parse_legacy_account_name("Cash") == ("Cash", None)
# ---------------------------------------------------------------------------
# account_utils.format_account_display_name
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("hierarchical", "expected"),
[
("Assets:Receivable:User-af983632", "Accounts Receivable - af983632"),
("Liabilities:Payable:User-cafebabe", "Accounts Payable - cafebabe"),
("Expenses:Food:Supplies", "Food & Supplies"),
("Assets:Cash", "Cash"),
("Assets", "Assets"), # too short — passes through
],
)
def test_format_account_display_name(hierarchical, expected):
assert au.format_account_display_name(hierarchical) == expected
# ---------------------------------------------------------------------------
# account_utils.get_account_type_from_hierarchical
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("name", "expected_type"),
[
("Assets:Cash", AccountType.ASSET),
("Liabilities:Payable:User-x", AccountType.LIABILITY),
("Equity:User-x", AccountType.EQUITY),
("Income:Accommodation", AccountType.REVENUE),
("Expenses:Food", AccountType.EXPENSE),
],
)
def test_get_account_type_from_hierarchical(name, expected_type):
assert au.get_account_type_from_hierarchical(name) == expected_type
def test_get_account_type_unknown_root_returns_none():
assert au.get_account_type_from_hierarchical("Other:Random") is None
# ---------------------------------------------------------------------------
# account_utils.migrate_account_name — round-trip legacy → hierarchical
# ---------------------------------------------------------------------------
def test_migrate_account_name_receivable():
out = au.migrate_account_name("Accounts Receivable - af983632", AccountType.ASSET)
assert out == "Assets:Receivable:User-af983632"
def test_migrate_account_name_expense_with_ampersand():
assert au.migrate_account_name("Food & Supplies", AccountType.EXPENSE) == (
"Expenses:Food:Supplies"
)
# ---------------------------------------------------------------------------
# core.validation — validate_journal_entry
# ---------------------------------------------------------------------------
def test_validate_journal_entry_balanced_passes():
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 100},
{"account_id": "b", "amount": -100},
],
)
def test_validate_journal_entry_unbalanced_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 100},
{"account_id": "b", "amount": -50},
],
)
assert "not balanced" in str(exc.value)
def test_validate_journal_entry_single_line_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[{"account_id": "a", "amount": 100}],
)
assert "at least 2 lines" in str(exc.value)
def test_validate_journal_entry_zero_amount_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"account_id": "a", "amount": 0},
{"account_id": "b", "amount": 0},
],
)
assert "amount = 0" in str(exc.value)
def test_validate_journal_entry_missing_account_id_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_journal_entry(
{"id": "x"},
[
{"amount": 100},
{"account_id": "b", "amount": -100},
],
)
assert "missing account_id" in str(exc.value)
# ---------------------------------------------------------------------------
# core.validation — validate_balance
# ---------------------------------------------------------------------------
def test_validate_balance_exact_match_passes():
val.validate_balance("acct", expected_balance_sats=1000, actual_balance_sats=1000)
def test_validate_balance_within_tolerance_passes():
val.validate_balance(
"acct", expected_balance_sats=1000, actual_balance_sats=1005, tolerance_sats=10,
)
def test_validate_balance_outside_tolerance_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_balance(
"acct", expected_balance_sats=1000, actual_balance_sats=1100, tolerance_sats=10,
)
assert "Balance assertion failed" in str(exc.value)
def test_validate_balance_fiat_mismatch_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_balance(
"acct",
expected_balance_sats=1000,
actual_balance_sats=1000,
expected_balance_fiat=Decimal("100.00"),
actual_balance_fiat=Decimal("99.50"),
tolerance_fiat=Decimal("0.10"),
fiat_currency="EUR",
)
assert "Fiat balance" in str(exc.value)
# ---------------------------------------------------------------------------
# core.validation — entry-specific validators
# ---------------------------------------------------------------------------
def test_validate_receivable_entry_positive_revenue_passes():
val.validate_receivable_entry("u", amount=100, revenue_account_type="revenue")
def test_validate_receivable_entry_zero_amount_raises():
with pytest.raises(val.ValidationError):
val.validate_receivable_entry("u", amount=0, revenue_account_type="revenue")
def test_validate_receivable_entry_wrong_account_type_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_receivable_entry("u", amount=100, revenue_account_type="expense")
assert "revenue account" in str(exc.value)
def test_validate_expense_entry_non_equity_requires_expense_account():
with pytest.raises(val.ValidationError) as exc:
val.validate_expense_entry(
"u", amount=100, expense_account_type="asset", is_equity=False,
)
assert "expense account" in str(exc.value)
def test_validate_expense_entry_equity_allows_non_expense_account():
"""Equity contributions bypass the expense-account requirement."""
val.validate_expense_entry(
"u", amount=100, expense_account_type="equity", is_equity=True,
)
def test_validate_payment_entry_negative_raises():
with pytest.raises(val.ValidationError):
val.validate_payment_entry("u", amount=-1)
# ---------------------------------------------------------------------------
# core.validation — validate_metadata
# ---------------------------------------------------------------------------
def test_validate_metadata_required_keys_missing_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"foo": 1}, required_keys=["bar", "baz"])
assert "bar" in str(exc.value) and "baz" in str(exc.value)
def test_validate_metadata_fiat_currency_without_amount_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_currency": "EUR"})
assert "both be present or both absent" in str(exc.value)
def test_validate_metadata_fiat_amount_without_currency_raises():
with pytest.raises(val.ValidationError):
val.validate_metadata({"fiat_amount": "10.00"})
@pytest.mark.xfail(
reason="libra/issues/38 — except clause doesn't catch decimal.InvalidOperation, "
"so the raw exception leaks instead of becoming ValidationError. Flip when fixed.",
strict=True,
)
def test_validate_metadata_fiat_amount_invalid_decimal_raises():
with pytest.raises(val.ValidationError) as exc:
val.validate_metadata({"fiat_amount": "not-a-number", "fiat_currency": "EUR"})
assert "Invalid fiat_amount" in str(exc.value)
def test_validate_metadata_both_present_passes():
val.validate_metadata({"fiat_amount": "100.50", "fiat_currency": "EUR"})
def test_validate_metadata_neither_present_passes():
val.validate_metadata({"source": "api"})

View file

@ -0,0 +1,212 @@
"""Reject / void pending entry flow — `POST /libra/api/v1/entries/{id}/reject`.
Captures the current (pre-issue #24) in-place mutation behaviour:
- Pending entries (`!` flag) can be rejected by a super user.
- Rejection appends `#voided` to the transaction line in the .beancount file
(no new transaction posted this is the only in-place edit path in libra).
- Voided entries are filtered out of balance queries.
- The reject endpoint only matches pending entries; cleared (`*`) ones return
404 because the search loop filters by `flag == '!'`.
PR #34 changes whether the user's `/entries/user` listing surfaces voided rows.
The test `test_voided_entry_excluded_from_user_journal` documents the current
("filtered") behaviour; flip it if/when that change lands.
When the reversing-entry refactor in issue #24 ships, these tests will need to
move from "void via tag append" to "void via reversal transaction." The shape
of the tests should still hold what changes is the on-disk evidence.
"""
from uuid import uuid4
import pytest
from .helpers import (
approve_entry,
get_balance,
list_user_entries,
post_expense,
reject_entry,
)
@pytest.mark.anyio
async def test_admin_can_reject_pending_expense(
client, super_user_headers, configured_user, standard_accounts,
):
"""Happy path: user submits expense → admin rejects → response includes
the entry id, balance still zero."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="15.00",
currency="EUR",
description=f"Reject me {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
result = await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
assert result.get("entry_id") == posted["id"]
balance = await get_balance(client, wallet_inkey=wallet.inkey)
assert not balance.get("fiat_balances"), (
f"voided entry should not surface in balance, got {balance}"
)
@pytest.mark.anyio
async def test_voided_entry_visible_in_user_journal(
client, super_user_headers, configured_user, standard_accounts,
):
"""Post-commit-1c89e69 behaviour: rejected entries remain visible in
the user's `/entries/user` listing so the user can see their own
rejected history rather than having it silently disappear.
The UI is expected to render these with a 'voided' visual marker
(PR #34 webapp companion). The balance query still excludes them
via the separate `tags` filter covered in
`test_admin_can_reject_pending_expense`.
"""
_, wallet = configured_user
tag = f"void-marker-{uuid4().hex[:6]}"
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="20.00",
currency="EUR",
description=tag,
expense_account=standard_accounts["expense_food"]["name"],
)
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
listing = await list_user_entries(client, wallet_inkey=wallet.inkey)
entries = listing.get("entries", [])
descriptions = [e.get("description") or "" for e in entries]
assert any(tag in d for d in descriptions), (
f"voided entry should remain visible in user journal post-#34, "
f"got descriptions: {descriptions}"
)
voided = next(
(e for e in entries if tag in (e.get("description") or "")), None,
)
assert voided is not None
assert "voided" in voided.get("tags", []), (
f"voided entry should be tagged 'voided' for UI styling, "
f"got tags: {voided.get('tags')}"
)
@pytest.mark.anyio
async def test_reject_unknown_entry_returns_404(
client, super_user_headers,
):
"""An entry id that doesn't exist anywhere in the ledger 404s."""
bogus_id = uuid4().hex[:16]
r = await client.post(
f"/libra/api/v1/entries/{bogus_id}/reject",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
assert "not found" in r.text.lower()
@pytest.mark.anyio
async def test_reject_already_cleared_entry_returns_404(
client, super_user_headers, configured_user, standard_accounts,
):
"""The reject lookup filters by `flag == '!'` so already-approved
(cleared) entries are indistinguishable from non-existent ones
both 404."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="11.00",
currency="EUR",
description=f"Approve-then-reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await approve_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers=super_user_headers,
)
assert r.status_code == 404, f"expected 404, got {r.status_code}: {r.text}"
@pytest.mark.anyio
async def test_non_super_user_cannot_reject(
client, configured_user, standard_accounts,
):
"""Reject endpoint uses libra's `require_super_user` — wallet
admin-key of a non-super user is forbidden."""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="13.00",
currency="EUR",
description=f"Forbidden reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
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()
@pytest.mark.anyio
async def test_double_reject_returns_404_on_second_call(
client, super_user_headers, configured_user, standard_accounts,
):
"""After a successful reject the entry is no longer matched by the
lookup (it's still flag `!` but its journal-listing-filter behaviour
is "voided"). A second reject 404s rather than mutating again.
Documents the de-facto idempotency story: it's "first wins, repeat
fails cleanly" rather than "repeat is a no-op success." If the
reversing-entry refactor (#24) reshapes this, the test will reveal it.
"""
_, wallet = configured_user
posted = await post_expense(
client,
wallet_inkey=wallet.inkey,
user_wallet_id=wallet.id,
amount="9.00",
currency="EUR",
description=f"Double reject {uuid4().hex[:6]}",
expense_account=standard_accounts["expense_food"]["name"],
)
await reject_entry(
client, super_user_headers=super_user_headers, entry_id=posted["id"],
)
r = await client.post(
f"/libra/api/v1/entries/{posted['id']}/reject",
headers=super_user_headers,
)
# First reject succeeded; second reject either 404 (entry still flag !
# but matched-by-tag elsewhere) or 200 with idempotent no-op. Lock in
# whichever the current code does so a future change to the reject
# path forces a deliberate decision.
assert r.status_code in (200, 404), (
f"second reject should be deterministic, got {r.status_code}: {r.text}"
)

View file

@ -9,7 +9,6 @@ from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import (
check_super_user,
check_user_exists,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
@ -53,6 +52,7 @@ from .models import (
LibraSettings,
CreateAccount,
CreateAccountPermission,
CreateChartAccount,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
@ -434,6 +434,39 @@ async def api_get_journal_entries(
return enriched_entries
# Link prefixes written by libra itself (vs user-supplied references):
# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the
# legacy libra-{id} identity link.
_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-")
def _extract_entry_id(entry: dict) -> Optional[str]:
"""Resolve the canonical libra entry id for a Fava transaction.
The ``entry-id`` transaction metadata is the single source of truth
written by every libra entry formatter since dfdcc44. Ledger history
predating it carries only a ``libra-{id}`` link; parse that as a
fallback so old entries still resolve.
Returns None when no id can be determined (e.g. settlement/payment
transactions, which are not approvable).
"""
meta = entry.get("meta", {})
entry_id = meta.get("entry-id")
if entry_id:
return str(entry_id)
# Legacy fallback: pre-entry-id ledger history (single libra-{id} link)
links = entry.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if link_clean.startswith("libra-"):
return link_clean[len("libra-"):]
return None
@libra_api_router.get("/api/v1/entries/user")
async def api_get_user_entries(
wallet: WalletTypeInfo = Depends(require_invoice_key),
@ -487,10 +520,6 @@ async def api_get_user_entries(
if e.get("flag") in _SYNTHETIC_FLAGS:
continue
# Skip voided transactions
if "voided" in e.get("tags", []):
continue
# Extract user ID from metadata or account names
user_id_match = None
entry_meta = e.get("meta", {})
@ -528,18 +557,9 @@ async def api_get_user_entries(
continue
# Extract data for frontend
# Extract entry ID from links
entry_id = None
# Resolve canonical entry ID (metadata first, link fallback)
entry_id = _extract_entry_id(e)
links = e.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if "libra-" in link_clean:
parts = link_clean.split("libra-")
if len(parts) > 1:
entry_id = parts[-1]
break
# Extract amount from postings
amount_sats = 0
@ -596,13 +616,15 @@ async def api_get_user_entries(
fiat_amount = float(cost_match.group(1))
fiat_currency = cost_match.group(2)
# Extract reference from links (first non-libra link)
# Extract reference from links (first link that isn't a
# libra-system link: typed entry/settlement links, lightning
# payment links, or the legacy libra-{id} identity link)
reference = None
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
link_clean = link.lstrip('^')
if not link_clean.startswith("libra-") and not link_clean.startswith("ln-"):
if not link_clean.startswith(_SYSTEM_LINK_PREFIXES):
reference = link_clean
break
@ -782,19 +804,9 @@ async def api_get_pending_entries(
for e in all_entries:
# Only include pending transactions that are NOT voided
if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []):
# Extract entry ID from links field
entry_id = None
# Resolve canonical entry ID (metadata first, link fallback)
entry_id = _extract_entry_id(e)
links = e.get("links", [])
if isinstance(links, (list, set)):
for link in links:
if isinstance(link, str):
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
if "libra-" in link_clean:
parts = link_clean.split("libra-")
if len(parts) > 1:
entry_id = parts[-1]
break
# Extract user ID from metadata or account names
user_id = None
@ -910,7 +922,11 @@ async def api_create_journal_entry(
Submits entry to Fava/Beancount.
"""
from .fava_client import get_fava_client
from .beancount_format import format_transaction, format_posting_with_cost
from .beancount_format import (
format_transaction,
format_posting_with_cost,
sanitize_link,
)
# Validate that entry balances to zero
total = sum(line.amount for line in data.lines)
@ -979,7 +995,7 @@ async def api_create_journal_entry(
tags = data.meta.get("tags", [])
links = data.meta.get("links", [])
if data.reference:
links.append(data.reference)
links.append(sanitize_link(data.reference))
# Entry metadata (excluding tags and links which go at transaction level)
entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
@ -1132,7 +1148,7 @@ async def api_create_expense_entry(
# Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client
from .beancount_format import format_expense_entry, sanitize_link
from .beancount_format import format_expense_entry
fava = get_fava_client()
@ -1144,12 +1160,8 @@ async def api_create_expense_entry(
import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Add libra ID as reference/link (sanitized for Beancount)
libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Format Beancount entry
# Format Beancount entry. Identity travels as entry-id metadata +
# exp-{entry_id} link; the user reference becomes its own link.
entry = format_expense_entry(
user_id=wallet.wallet.user,
expense_account=expense_account.name,
@ -1160,8 +1172,8 @@ async def api_create_expense_entry(
is_equity=data.is_equity,
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
reference=libra_reference,
entry_id=entry_id # Pass entry_id so all links match
reference=data.reference,
entry_id=entry_id
)
# Submit to Fava
@ -1175,7 +1187,7 @@ async def api_create_expense_entry(
entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(),
reference=libra_reference,
reference=data.reference,
flag=JournalEntryFlag.PENDING,
meta=entry_meta,
lines=[
@ -1266,17 +1278,15 @@ async def api_create_income_entry(
# Submit to Fava
from .fava_client import get_fava_client
from .beancount_format import format_income_entry, sanitize_link
from .beancount_format import format_income_entry
fava = get_fava_client()
import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Identity travels as entry-id metadata + inc-{entry_id} link; the
# user reference becomes its own link.
entry = format_income_entry(
user_id=wallet.wallet.user,
user_account=user_account.name,
@ -1286,7 +1296,7 @@ async def api_create_income_entry(
entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(),
fiat_currency=fiat_currency,
fiat_amount=data.amount,
reference=libra_reference,
reference=data.reference,
entry_id=entry_id,
)
@ -1307,7 +1317,7 @@ async def api_create_income_entry(
entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.user,
created_at=datetime.now(),
reference=libra_reference,
reference=data.reference,
flag=JournalEntryFlag.PENDING,
meta=entry_meta,
lines=[
@ -1334,7 +1344,7 @@ async def api_create_income_entry(
@libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
async def api_create_receivable_entry(
data: ReceivableEntry,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> JournalEntry:
"""
Create an accounts receivable entry (user owes libra).
@ -1393,7 +1403,7 @@ async def api_create_receivable_entry(
# Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client
from .beancount_format import format_receivable_entry, sanitize_link
from .beancount_format import format_receivable_entry
fava = get_fava_client()
@ -1405,12 +1415,8 @@ async def api_create_receivable_entry(
import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Add libra ID as reference/link (sanitized for Beancount)
libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Format Beancount entry
# Format Beancount entry. Identity travels as entry-id metadata +
# rcv-{entry_id} link; the user reference becomes its own link.
entry = format_receivable_entry(
user_id=data.user_id,
revenue_account=revenue_account.name,
@ -1420,8 +1426,8 @@ async def api_create_receivable_entry(
entry_date=datetime.now().date(),
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
reference=libra_reference,
entry_id=entry_id # Pass entry_id so all links match
reference=data.reference,
entry_id=entry_id
)
# Submit to Fava
@ -1433,9 +1439,9 @@ async def api_create_receivable_entry(
id=entry_id, # Use the generated libra entry ID
description=data.description + description_suffix,
entry_date=datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_by=auth.user_id,
created_at=datetime.now(),
reference=libra_reference, # Use libra reference with unique ID
reference=data.reference,
flag=JournalEntryFlag.PENDING,
meta=entry_meta,
lines=[
@ -1462,7 +1468,7 @@ async def api_create_receivable_entry(
@libra_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED)
async def api_create_revenue_entry(
data: RevenueEntry,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> JournalEntry:
"""
Create a revenue entry (libra receives payment).
@ -1471,7 +1477,7 @@ async def api_create_revenue_entry(
Submits entry to Fava/Beancount.
"""
from .fava_client import get_fava_client
from .beancount_format import format_revenue_entry, sanitize_link
from .beancount_format import format_revenue_entry
# Get revenue account
revenue_account = await get_account_by_name(data.revenue_account)
@ -1521,11 +1527,8 @@ async def api_create_revenue_entry(
import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Add libra ID as reference/link (sanitized for Beancount)
libra_reference = f"libra-{entry_id}"
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Identity travels as entry-id metadata; the user reference becomes
# its own link.
entry = format_revenue_entry(
payment_account=payment_account.name,
revenue_account=revenue_account.name,
@ -1534,7 +1537,8 @@ async def api_create_revenue_entry(
entry_date=datetime.now().date(),
fiat_currency=fiat_currency,
fiat_amount=fiat_amount,
reference=libra_reference # Use libra reference with unique ID
reference=data.reference,
entry_id=entry_id,
)
# Submit to Fava
@ -1547,9 +1551,9 @@ async def api_create_revenue_entry(
id=entry_id,
description=data.description,
entry_date=datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_by=auth.user_id,
created_at=datetime.now(),
reference=libra_reference,
reference=data.reference,
flag=JournalEntryFlag.CLEARED,
lines=[], # Empty - entry is stored in Fava, not Libra DB
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')}
@ -1613,7 +1617,8 @@ async def api_get_my_balance(
return UserBalance(
user_id=wallet.wallet.user,
balance=balance_data["balance"],
accounts=[], # Could populate from balance_data["accounts"] if needed
accounts=[],
account_balances=balance_data.get("accounts", []),
fiat_balances=balance_data["fiat_balances"],
total_expenses_sats=totals["total_expenses_sats"],
total_expenses_fiat=totals["total_expenses_fiat"],
@ -1934,65 +1939,6 @@ async def api_record_payment(
}
@libra_api_router.post("/api/v1/pay-user")
async def api_pay_user(
user_id: str,
amount: int,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Record a payment from libra to user (reduces what libra owes user).
Admin only.
"""
# Get user's payable account (what libra owes)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Format payment entry and submit to Fava
# DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning
from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
fava = get_fava_client()
# Get unsettled expense entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount,
description=f"Payment to user {user_id[:8]}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user
reference=f"PAY-{user_id[:8]}",
settled_entry_links=settled_links
)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"new_balance": balance_data["balance"],
"message": "Payment recorded successfully",
}
@libra_api_router.post("/api/v1/receivables/settle")
async def api_settle_receivable(
data: SettleReceivable,
@ -2054,7 +2000,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()
@ -2064,9 +2014,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,
@ -2119,7 +2166,7 @@ async def api_settle_receivable(
if "meta" not in entry:
entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["settled-by"] = wallet.wallet.user
entry["meta"]["settled-by"] = auth.user_id
if data.txid:
entry["meta"]["txid"] = data.txid
@ -2493,7 +2540,7 @@ async def api_expense_report(
@libra_api_router.get("/api/v1/reports/contributions")
async def api_contributions_report(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get user contribution report using BQL.
@ -2562,7 +2609,7 @@ async def api_contributions_report(
async def api_get_unsettled_entries(
user_id: str,
entry_type: str = "expense",
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get unsettled expense or receivable entries for a user.
@ -2770,7 +2817,7 @@ async def api_approve_manual_payment_request(
# Approve the request with Fava entry reference
entry_id = f"fava-{datetime.now().timestamp()}"
return await approve_manual_payment_request(
request_id, wallet.wallet.user, entry_id
request_id, auth.user_id, entry_id
)
@ -2818,21 +2865,14 @@ async def api_approve_expense_entry(
# 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching libra ID in links
# 2. Find the pending transaction with matching canonical entry id
target_entry = None
for entry in all_entries:
# Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
links = entry.get("links", [])
for link in links:
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
# Check if this entry has our libra ID
if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry = entry
break
if target_entry:
if _extract_entry_id(entry) == entry_id:
target_entry = entry
break
if not target_entry:
@ -2934,21 +2974,14 @@ async def api_reject_expense_entry(
# 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries()
# 2. Find the entry with matching libra ID in links
# 2. Find the pending transaction with matching canonical entry id
target_entry = None
for entry in all_entries:
# Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!":
links = entry.get("links", [])
for link in links:
# Strip ^ prefix if present (Beancount link syntax)
link_clean = link.lstrip('^')
# Check if this entry has our libra ID
if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"):
target_entry = entry
break
if target_entry:
if _extract_entry_id(entry) == entry_id:
target_entry = entry
break
if not target_entry:
@ -3344,18 +3377,18 @@ async def api_get_user_info(
@libra_api_router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED)
async def api_grant_equity_eligibility(
data: CreateUserEquityStatus,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus:
"""Grant equity contribution eligibility to a user (admin only)"""
from .crud import create_or_update_user_equity_status
return await create_or_update_user_equity_status(data, wallet.wallet.user)
return await create_or_update_user_equity_status(data, auth.user_id)
@libra_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}")
async def api_revoke_equity_eligibility(
user_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus:
"""Revoke equity contribution eligibility from a user (admin only)"""
from .crud import revoke_user_equity_eligibility
@ -3371,7 +3404,7 @@ async def api_revoke_equity_eligibility(
@libra_api_router.get("/api/v1/admin/equity-eligibility")
async def api_list_equity_eligible_users(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[UserEquityStatus]:
"""List all equity-eligible users (admin only)"""
from .crud import get_all_equity_eligible_users
@ -3385,7 +3418,7 @@ async def api_list_equity_eligible_users(
@libra_api_router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED)
async def api_grant_permission(
data: CreateAccountPermission,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> AccountPermission:
"""Grant account permission to a user (admin only)"""
# Validate that account exists
@ -3396,14 +3429,14 @@ async def api_grant_permission(
detail=f"Account with ID '{data.account_id}' not found",
)
return await create_account_permission(data, wallet.wallet.user)
return await create_account_permission(data, auth.user_id)
@libra_api_router.get("/api/v1/admin/permissions")
async def api_list_permissions(
user_id: str | None = None,
account_id: str | None = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]:
"""
List account permissions (admin only).
@ -3436,7 +3469,7 @@ async def api_list_permissions(
@libra_api_router.delete("/api/v1/admin/permissions/{permission_id}")
async def api_revoke_permission(
permission_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Revoke (delete) an account permission (admin only)"""
# Verify permission exists
@ -3458,7 +3491,7 @@ async def api_revoke_permission(
@libra_api_router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permissions(
permissions: list[CreateAccountPermission],
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]:
"""Grant multiple account permissions at once (admin only)"""
created_permissions = []
@ -3472,7 +3505,7 @@ async def api_bulk_grant_permissions(
detail=f"Account with ID '{perm_data.account_id}' not found",
)
perm = await create_account_permission(perm_data, wallet.wallet.user)
perm = await create_account_permission(perm_data, auth.user_id)
created_permissions.append(perm)
return created_permissions
@ -3481,7 +3514,7 @@ async def api_bulk_grant_permissions(
@libra_api_router.post("/api/v1/admin/permissions/bulk-grant", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permission_to_users(
data: "BulkGrantPermission",
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> "BulkGrantResult":
"""
Grant the same permission to multiple users at once (admin only).
@ -3515,7 +3548,7 @@ async def api_bulk_grant_permission_to_users(
expires_at=data.expires_at,
notes=data.notes,
)
perm = await create_account_permission(perm_data, wallet.wallet.user)
perm = await create_account_permission(perm_data, auth.user_id)
granted.append(perm)
except Exception as e:
failed.append({
@ -3625,10 +3658,136 @@ async def api_get_account_hierarchy(
# ===== ACCOUNT SYNC ENDPOINTS =====
_VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:")
def _is_valid_account_component(component: str, *, is_root: bool) -> bool:
"""Validate one ':'-separated account component against Beancount's grammar.
Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*``
(must start with an uppercase letter); a sub component matches
``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body
chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware
str methods (libra's runtime has no beancount — Fava is a separate service),
so non-ASCII letters are accepted exactly as Beancount accepts them.
"""
if not component:
return False
first, rest = component[0], component[1:]
first_ok = (first.isalpha() and first.isupper()) or (
not is_root and first.isdecimal()
)
if not first_ok:
return False
return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest)
def _validate_account_name(name: str) -> None:
"""Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account.
The UI guards this client-side, but the endpoint is reachable directly via
API, so this is the load-bearing check before the name is written into the
ledger source. Requires a root plus at least one sub-component.
"""
parts = name.split(":")
valid = (
len(parts) >= 2
and _is_valid_account_component(parts[0], is_root=True)
and all(_is_valid_account_component(p, is_root=False) for p in parts[1:])
)
if not valid:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Invalid account name {name!r}: each ':'-separated part must be "
"letters/digits/hyphens, the root starting with an uppercase "
"letter (sub-accounts may start with a digit), with at least one "
"sub-account (e.g. Expenses:Food)."
),
)
@libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
async def api_admin_add_chart_account(
payload: CreateChartAccount,
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Add a chart-of-accounts entry (super-user only).
Writes an Open directive to accounts/chart.beancount via Fava's /api/source,
then syncs the account into Libra's DB so permissions can be granted on it.
Per-user accounts (matching :User-xxxxxxxx) take a different code path via
crud.get_or_create_user_account and are not created through this endpoint.
"""
from .fava_client import get_fava_client
if not payload.name.startswith(_VALID_ACCOUNT_PREFIXES):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Account name must start with one of "
f"{', '.join(_VALID_ACCOUNT_PREFIXES)} (got {payload.name!r})"
),
)
_validate_account_name(payload.name)
logger.info(
f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
f"with currencies {payload.currencies}"
)
fava = get_fava_client()
metadata: dict = {"added_by": auth.user_id[:8], "source": "admin-ui"}
if payload.description:
metadata["description"] = payload.description
result = await fava.add_account(
account_name=payload.name,
currencies=payload.currencies,
target_file="accounts/chart.beancount",
metadata=metadata,
)
from .account_sync import sync_single_account_from_beancount
if result.get("already_existed"):
# The Open directive is already in the ledger. If it's also already
# mirrored into libra's DB, it's a true duplicate → 409. If not (a prior
# sync failed — there's no cross-DB atomicity — or it was opened out of
# band), mirror it now so it becomes grantable instead of being stranded
# with no recovery path.
from .crud import get_account_by_name
if await get_account_by_name(payload.name) is not None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {payload.name} already exists",
)
synced = await sync_single_account_from_beancount(payload.name)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
"already_existed": True,
}
# Mirror into libra DB so permissions / metadata layer sees it.
synced = await sync_single_account_from_beancount(payload.name)
return {
"success": True,
"account_name": payload.name,
"synced_to_libra_db": synced,
}
@libra_api_router.post("/api/v1/admin/accounts/sync")
async def api_sync_all_accounts(
force_full_sync: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync all accounts from Beancount to Libra DB (admin only).
@ -3644,7 +3803,7 @@ async def api_sync_all_accounts(
"""
from .account_sync import sync_accounts_from_beancount
logger.info(f"Admin {wallet.wallet.user[:8]} triggered account sync (force={force_full_sync})")
logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})")
try:
stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync)
@ -3661,7 +3820,7 @@ async def api_sync_all_accounts(
@libra_api_router.post("/api/v1/admin/accounts/sync/{account_name:path}")
async def api_sync_single_account(
account_name: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Sync a single account from Beancount to Libra DB (admin only).
@ -3677,7 +3836,7 @@ async def api_sync_single_account(
"""
from .account_sync import sync_single_account_from_beancount
logger.info(f"Admin {wallet.wallet.user[:8]} triggered sync for account: {account_name}")
logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}")
try:
created = await sync_single_account_from_beancount(account_name)
@ -3707,7 +3866,7 @@ async def api_sync_single_account(
@libra_api_router.get("/api/v1/admin/roles")
async def api_get_all_roles(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list:
"""Get all roles (admin only)"""
from . import crud
@ -3737,13 +3896,13 @@ async def api_get_all_roles(
@libra_api_router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED)
async def api_create_role(
data: CreateRole,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Create a new role (admin only)"""
from . import crud
try:
role = await crud.create_role(data, created_by=wallet.wallet.user)
role = await crud.create_role(data, created_by=auth.user_id)
return {
"id": role.id,
"name": role.name,
@ -3763,7 +3922,7 @@ async def api_create_role(
@libra_api_router.get("/api/v1/admin/roles/{role_id}")
async def api_get_role(
role_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Get a specific role with its permissions and users (admin only)"""
from . import crud
@ -3813,7 +3972,7 @@ async def api_get_role(
async def api_update_role(
role_id: str,
data: UpdateRole,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Update a role (admin only)"""
from . import crud
@ -3838,7 +3997,7 @@ async def api_update_role(
@libra_api_router.delete("/api/v1/admin/roles/{role_id}")
async def api_delete_role(
role_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Delete a role (admin only) - cascades to role_permissions and user_roles"""
from . import crud
@ -3861,7 +4020,7 @@ async def api_delete_role(
async def api_add_role_permission(
role_id: str,
data: CreateRolePermission,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Add a permission to a role (admin only)"""
from . import crud
@ -3899,7 +4058,7 @@ async def api_add_role_permission(
async def api_delete_role_permission(
role_id: str,
permission_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Remove a permission from a role (admin only)"""
from . import crud
@ -3914,7 +4073,7 @@ async def api_delete_role_permission(
@libra_api_router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED)
async def api_assign_user_role(
data: AssignUserRole,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Assign a user to a role (admin only)"""
from . import crud
@ -3928,7 +4087,7 @@ async def api_assign_user_role(
)
try:
user_role = await crud.assign_user_role(data, granted_by=wallet.wallet.user)
user_role = await crud.assign_user_role(data, granted_by=auth.user_id)
return {
"id": user_role.id,
"user_id": user_role.user_id,
@ -3949,7 +4108,7 @@ async def api_assign_user_role(
@libra_api_router.get("/api/v1/admin/user-roles/{user_id}")
async def api_get_user_roles(
user_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Get all roles assigned to a user (admin only)"""
from . import crud
@ -3982,7 +4141,7 @@ async def api_get_user_roles(
@libra_api_router.delete("/api/v1/admin/user-roles/{user_role_id}")
async def api_revoke_user_role(
user_role_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Revoke a user's role assignment (admin only)"""
from . import crud
@ -3993,7 +4152,7 @@ async def api_revoke_user_role(
@libra_api_router.get("/api/v1/admin/users/roles")
async def api_get_all_user_roles(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
):
"""Get all user role assignments (admin only)"""
from . import crud