diff --git a/CLAUDE.md b/CLAUDE.md index 97e546f..b58591c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,9 +49,13 @@ Libra is a double-entry bookkeeping extension for LNbits that enables collective ### Database Schema -**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. +**Note**: With Fava integration, Libra maintains a local cache of some data but delegates authoritative balance calculations to Beancount/Fava. -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. +**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) **extension_settings**: Libra wallet configuration (admin-only) - `libra_wallet_id` - The LNbits wallet used for Libra operations @@ -209,8 +213,7 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata - meta={"entry-id": "a1b2c3d4e5f60708"} + links=["libra-entry-123"] ) # Submit to Fava @@ -218,8 +221,6 @@ 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 @@ -281,8 +282,7 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["exp-0123456789abcdef"], - meta={"entry-id": "0123456789abcdef"} + links=["libra-tx-123"] ) client = get_fava_client() @@ -310,13 +310,6 @@ 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 diff --git a/account_sync.py b/account_sync.py index 3d82381..7e875f8 100644 --- a/account_sync.py +++ b/account_sync.py @@ -320,20 +320,11 @@ 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( diff --git a/beancount_format.py b/beancount_format.py index f233ee5..956a4ee 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -804,139 +804,6 @@ 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, @@ -945,8 +812,7 @@ def format_revenue_entry( entry_date: date, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None, - entry_id: Optional[str] = None + reference: Optional[str] = None ) -> Dict[str, Any]: """ Format a revenue entry (libra receives payment directly). @@ -963,8 +829,7 @@ def format_revenue_entry( entry_date: Date of payment fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) - reference: Optional reference (invoice ID, etc.) — stored as its own link - entry_id: Optional unique entry ID (generated if not provided) + reference: Optional reference Returns: Fava API entry dict @@ -980,9 +845,6 @@ 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 @@ -1007,13 +869,12 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "libra-api", - "entry-id": entry_id + "source": "libra-api" } links = [] if reference: - links.append(sanitize_link(reference)) + links.append(reference) return format_transaction( date_val=entry_date, @@ -1024,68 +885,3 @@ def format_revenue_entry( links=links, meta=entry_meta ) - - -def format_income_entry( - user_id: str, - user_account: str, - revenue_account: str, - amount_sats: int, - description: str, - entry_date: date, - fiat_currency: str, - fiat_amount: Decimal, - reference: Optional[str] = None, - entry_id: Optional[str] = None, -) -> Dict[str, Any]: - """ - Format a user-submitted income/revenue entry for Fava (pending approval). - - Mirrors format_expense_entry: pending flag (!) for super-user review, - fiat-first price notation (@@ SATS) for BQL queryability, unique link - (^inc-{entry_id}) for tracking through the approve/reject flow. - - Postings: DR user_account (Assets:Receivable:User-{id} — user owes - the entity until they hand the cash over), CR revenue_account. - """ - if not fiat_currency or not fiat_amount or fiat_amount <= 0: - raise ValueError("fiat_currency and a positive fiat_amount are required for income entries") - - if not entry_id: - entry_id = generate_entry_id() - - fiat_amount_abs = abs(fiat_amount) - sats_abs = abs(amount_sats) - - narration = f"{description} ({fiat_amount_abs:.2f} {fiat_currency})" - - postings = [ - { - "account": user_account, - "amount": f"{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", - }, - { - "account": revenue_account, - "amount": f"-{fiat_amount_abs:.2f} {fiat_currency} @@ {sats_abs} SATS", - }, - ] - - entry_meta = { - "user-id": user_id, - "source": "libra-api", - "entry-id": entry_id, - } - - links = [f"inc-{entry_id}"] - if reference: - links.append(sanitize_link(reference)) - - return format_transaction( - date_val=entry_date, - flag="!", # Pending - requires admin approval - narration=narration, - postings=postings, - tags=["income-entry"], - links=links, - meta=entry_meta, - ) diff --git a/crud.py b/crud.py index 0692806..b2b43dd 100644 --- a/crud.py +++ b/crud.py @@ -250,13 +250,9 @@ 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=None, + currencies=["EUR", "SATS", "USD"], # Support common currencies metadata={ "user_id": user_id, "description": f"User-specific {account_type.value} account" diff --git a/fava_client.py b/fava_client.py index eaed06b..572cd95 100644 --- a/fava_client.py +++ b/fava_client.py @@ -18,7 +18,6 @@ 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 @@ -31,68 +30,6 @@ 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 ` open ` directive line (re.MULTILINE), - with `` 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. @@ -129,46 +66,6 @@ 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. @@ -869,15 +766,10 @@ 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' OR account ~ 'Credit') + AND (account ~ 'Payable' OR account ~ 'Receivable') AND flag = '*' GROUP BY account, currency """ @@ -935,66 +827,6 @@ class FavaClient: "accounts": accounts } - async def get_user_lifetime_totals_bql(self, user_id: str) -> Dict[str, Any]: - """ - Get lifetime totals of expenses submitted and income recorded by this user. - - Sums original entries only (tag-filtered) — does not net against payments - or other reconciliation activity, so totals match "amounts ever entered". - - Args: - user_id: User ID - - Returns: - { - "total_expenses_sats": int, - "total_expenses_fiat": {"EUR": Decimal("...")}, - "total_income_sats": int, - "total_income_fiat": {"EUR": Decimal("...")}, - } - """ - from decimal import Decimal - - user_id_prefix = user_id[:8] - - async def _sum_for(account_pattern: str, tag: str): - query = f""" - SELECT currency, sum(number), sum(weight) - WHERE account ~ '{account_pattern}:User-{user_id_prefix}' - AND '{tag}' IN tags - AND flag = '*' - GROUP BY currency - """ - result = await self.query_bql(query) - sats_total = 0 - fiat_total: Dict[str, Decimal] = {} - for row in result["rows"]: - currency, number_sum, weight_sum = row - # Skip SATS-currency rows (payment/reconciliation legs) - if currency == "SATS": - continue - if isinstance(weight_sum, dict) and "SATS" in weight_sum: - sats_total += abs(int(Decimal(str(weight_sum["SATS"])))) - fiat_amount = abs(Decimal(str(number_sum))) if number_sum else Decimal(0) - if fiat_amount > 0: - fiat_total[currency] = fiat_total.get(currency, Decimal(0)) + fiat_amount - return sats_total, fiat_total - - exp_sats, exp_fiat = await _sum_for("Liabilities:Payable", "expense-entry") - inc_sats, inc_fiat = await _sum_for("Assets:Receivable", "income-entry") - - logger.info( - f"User {user_id[:8]} lifetime totals (BQL): " - f"expenses={exp_sats} sats {dict(exp_fiat)}, income={inc_sats} sats {dict(inc_fiat)}" - ) - - return { - "total_expenses_sats": exp_sats, - "total_expenses_fiat": exp_fiat, - "total_income_sats": inc_sats, - "total_income_fiat": inc_fiat, - } - async def get_all_user_balances_bql(self) -> List[Dict[str, Any]]: """ Get balances for all users using BQL with currency-grouped aggregation. @@ -1024,11 +856,10 @@ class FavaClient: """ from decimal import Decimal - # 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. + # GROUP BY currency prevents mixing EUR and SATS face values in sum(number) query = """ SELECT account, currency, sum(number), sum(weight) - WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-' OR account ~ 'Credit:User-') + WHERE (account ~ 'Payable:User-' OR account ~ 'Receivable:User-') AND flag = '*' GROUP BY account, currency """ @@ -1593,23 +1424,16 @@ class FavaClient: async def add_account( self, account_name: str, - currencies: Optional[list[str]] = None, + currencies: list[str], 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 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 uses /api/source to directly edit the Beancount file. This method implements optimistic concurrency control with retry logic: - Acquires a global write lock before modifying the ledger @@ -1622,8 +1446,6 @@ 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: @@ -1633,18 +1455,17 @@ class FavaClient: ChecksumConflictError: If all retry attempts fail due to concurrent modifications Example: - # User-account names route to accounts/users.beancount automatically. + # Add a user's receivable account result = await fava.add_account( - account_name="Assets:Receivable:User-abc12345", + account_name="Assets:Receivable:User-abc123", currencies=["EUR", "SATS", "USD"], - metadata={"user_id": "abc12345", "description": "User receivables"} + metadata={"user_id": "abc123", "description": "User receivables"} ) - # Static / admin-added chart entries route to accounts/chart.beancount. + # Add a user's payable account result = await fava.add_account( - account_name="Expenses:NewCategory", - currencies=["EUR"], - target_file="accounts/chart.beancount", + account_name="Liabilities:Payable:User-abc123", + currencies=["EUR", "SATS"] ) """ from datetime import date as date_type @@ -1652,12 +1473,6 @@ 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): @@ -1665,10 +1480,18 @@ class FavaClient: async with self._write_lock: try: async with httpx.AsyncClient(timeout=self.timeout) as client: - # Step 1: Get current source file (fresh read on each attempt) + # 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) response = await client.get( f"{self.base_url}/source", - params={"filename": target_file} + params={"filename": file_path} ) response.raise_for_status() source_data = response.json()["data"] @@ -1676,56 +1499,47 @@ class FavaClient: sha256sum = source_data["sha256sum"] source = source_data["source"] - # 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 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 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. + # Step 4: Find insertion point (after last Open directive AND its metadata) lines = source.split('\n') - insert_index = len(lines) + 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 - # 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] + # Step 5: Format Open directive as Beancount text + currencies_str = ", ".join(currencies) + open_lines = [ + "", + f"{opening_date.isoformat()} open {account_name} {currencies_str}" + ] # 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}: "{_escape_beancount_string(value)}"' - ) + open_lines.append(f' {key}: "{value}"') else: open_lines.append(f' {key}: {value}') - # Step 5: Insert into source + # Step 6: Insert into source for i, line in enumerate(open_lines): lines.insert(insert_index + i, line) new_source = '\n'.join(lines) - # Step 6: Update source file via PUT /api/source + # Step 7: Update source file via PUT /api/source update_payload = { - "file_path": target_file, + "file_path": file_path, "source": new_source, "sha256sum": sha256sum } @@ -1738,8 +1552,8 @@ class FavaClient: response.raise_for_status() result = response.json() - logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") - return {**result, "already_existed": False} + logger.info(f"Added account {account_name} to Beancount file with currencies {currencies}") + return result except httpx.HTTPStatusError as e: # Check for checksum conflict (HTTP 412 Precondition Failed or similar) @@ -2053,10 +1867,6 @@ 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): @@ -2070,21 +1880,9 @@ 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. diff --git a/migrations.py b/migrations.py index 9c38c55..3cff47b 100644 --- a/migrations.py +++ b/migrations.py @@ -240,7 +240,7 @@ async def m001_initial(db): # ACCOUNT PERMISSIONS TABLE # ========================================================================= # Granular access control for accounts - # Permission types: read, submit_expense, submit_income, manage + # Permission types: read, submit_expense, manage # Supports hierarchical inheritance (parent account permissions cascade) await db.execute( diff --git a/models.py b/models.py index c8632d0..eaf75c4 100644 --- a/models.py +++ b/models.py @@ -48,17 +48,6 @@ 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 @@ -100,17 +89,7 @@ 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 - total_expenses_fiat: dict[str, Decimal] = {} - total_income_sats: int = 0 - total_income_fiat: dict[str, Decimal] = {} class ExpenseEntry(BaseModel): @@ -148,24 +127,6 @@ class RevenueEntry(BaseModel): currency: Optional[str] = None # If None, amount is in satoshis. Otherwise, fiat currency code -class IncomeEntry(BaseModel): - """Helper model for user-facing income/revenue submission (pending approval). - - The user records that they personally received money on the entity's - behalf — so the postings are DR Assets:Receivable:User-{id} / CR - revenue_account. The user now owes the entity until they settle via - the existing /settle-receivable flow. Symmetric with ExpenseEntry, - which credits Liabilities:Payable:User-{id} (entity owes user). - """ - - description: str - amount: Decimal # Fiat amount in the specified currency - revenue_account: str # Income/Revenue account name or ID - currency: str # Required: fiat currency code (EUR, USD, etc.) - reference: Optional[str] = None - entry_date: Optional[datetime] = None - - class LibraSettings(BaseModel): """Settings for the Libra extension""" @@ -334,7 +295,6 @@ class PermissionType(str, Enum): """Types of permissions for account access""" READ = "read" # Can view account and its balance SUBMIT_EXPENSE = "submit_expense" # Can submit expenses to this account - SUBMIT_INCOME = "submit_income" # Can submit income/revenue to this account MANAGE = "manage" # Can modify account (admin level) diff --git a/static/js/index.js b/static/js/index.js index 6f451f7..7c01c07 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -69,13 +69,6 @@ window.app = Vue.createApp({ userWalletId: '', loading: false }, - addAccountDialog: { - show: false, - rootType: 'Expenses', - subPath: '', - description: '', - loading: false - }, receivableDialog: { show: false, selectedUser: '', @@ -293,16 +286,6 @@ 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 => { @@ -583,55 +566,6 @@ 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' @@ -1708,34 +1642,6 @@ window.app = Vue.createApp({ formatSats(amount) { return new Intl.NumberFormat().format(amount) }, - 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', @@ -1773,10 +1679,6 @@ 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() { diff --git a/static/js/permissions.js b/static/js/permissions.js index b2a4f9d..948ac3a 100644 --- a/static/js/permissions.js +++ b/static/js/permissions.js @@ -53,11 +53,6 @@ window.app = Vue.createApp({ label: 'Submit Expense', description: 'Submit expenses to this account' }, - { - value: 'submit_income', - label: 'Submit Income', - description: 'Submit income/revenue entries to this account' - }, { value: 'manage', label: 'Manage', @@ -506,8 +501,6 @@ window.app = Vue.createApp({ return 'blue' case 'submit_expense': return 'green' - case 'submit_income': - return 'teal' case 'manage': return 'red' default: @@ -521,8 +514,6 @@ window.app = Vue.createApp({ return 'visibility' case 'submit_expense': return 'add_circle' - case 'submit_income': - return 'payments' case 'manage': return 'admin_panel_settings' default: @@ -709,7 +700,7 @@ window.app = Vue.createApp({ } }, - async editRole(role) { + editRole(role) { this.editingRole = true this.selectedRole = role this.roleForm = { @@ -717,28 +708,6 @@ window.app = Vue.createApp({ description: role.description || '', is_default: role.is_default || false } - this.rolePermissionsForView = [] - this.roleUsersForView = [] - - try { - const response = await LNbits.api.request( - 'GET', - `/libra/api/v1/admin/roles/${role.id}`, - this.g.user.wallets[0].adminkey - ) - this.rolePermissionsForView = [...(response.data.permissions || [])] - this.roleUsersForView = [...(response.data.users || [])] - } catch (error) { - console.error('Failed to load role details:', error) - this.$q.notify({ - type: 'negative', - message: 'Failed to load role permissions', - caption: error.message || 'Unknown error', - timeout: 5000 - }) - } - - await this.$nextTick() this.showCreateRoleDialog = true }, @@ -846,8 +815,6 @@ window.app = Vue.createApp({ this.showCreateRoleDialog = false this.editingRole = false this.selectedRole = null - this.roleUsersForView = [] - this.rolePermissionsForView = [] this.resetRoleForm() }, diff --git a/tasks.py b/tasks.py index 8ed5a33..f6f84cb 100644 --- a/tasks.py +++ b/tasks.py @@ -134,15 +134,8 @@ 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: diff --git a/templates/libra/index.html b/templates/libra/index.html index 5369f72..17eed2b 100644 --- a/templates/libra/index.html +++ b/templates/libra/index.html @@ -69,19 +69,18 @@ - + -
Pending Approvals
+
Pending Expense Approvals
+ + + Pending approval + + - - - {% raw %}{{ entry.description }}{% endraw %} {% raw %}{{ formatDate(entry.entry_date) }}{% endraw %} @@ -187,27 +186,16 @@ @@ -497,10 +485,7 @@ @@ -857,20 +845,7 @@ -
-
Chart of Accounts
- - -
+
Chart of Accounts
@@ -1245,63 +1220,6 @@
- - - - -
Add Account
- - - - - -
- Will create: {% raw %}{{ addAccountFullName }}{% endraw %} -
- - - -
- Creates an Open directive in the Beancount ledger and syncs it into Libra - so permissions can be granted. Per-user accounts are managed automatically. -
- -
- - Create Account - - Cancel -
-
-
-
- diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index 4c6c30b..0000000 --- a/tests/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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__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. diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 44b5c26..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,714 +0,0 @@ -"""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.` - when extensions live in the default `lnbits/extensions/` directory, or just - `` 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=` 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 diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 02d8f78..0000000 --- a/tests/helpers.py +++ /dev/null @@ -1,428 +0,0 @@ -"""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() diff --git a/tests/test_admin_chart_accounts_api.py b/tests/test_admin_chart_accounts_api.py deleted file mode 100644 index 023835f..0000000 --- a/tests/test_admin_chart_accounts_api.py +++ /dev/null @@ -1,170 +0,0 @@ -"""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" diff --git a/tests/test_balances_api.py b/tests/test_balances_api.py deleted file mode 100644 index 951f470..0000000 --- a/tests/test_balances_api.py +++ /dev/null @@ -1,452 +0,0 @@ -"""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}" - ) diff --git a/tests/test_entries_admin_api.py b/tests/test_entries_admin_api.py deleted file mode 100644 index 9cdc164..0000000 --- a/tests/test_entries_admin_api.py +++ /dev/null @@ -1,219 +0,0 @@ -"""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() diff --git a/tests/test_entries_user_api.py b/tests/test_entries_user_api.py deleted file mode 100644 index bdcaf4e..0000000 --- a/tests/test_entries_user_api.py +++ /dev/null @@ -1,211 +0,0 @@ -"""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 " ( )" 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}" - ) diff --git a/tests/test_entry_identity_api.py b/tests/test_entry_identity_api.py deleted file mode 100644 index 2b893ca..0000000 --- a/tests/test_entry_identity_api.py +++ /dev/null @@ -1,168 +0,0 @@ -"""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"] diff --git a/tests/test_lightning_api.py b/tests/test_lightning_api.py deleted file mode 100644 index 03a2ee2..0000000 --- a/tests/test_lightning_api.py +++ /dev/null @@ -1,205 +0,0 @@ -"""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}" diff --git a/tests/test_manual_payment_requests_api.py b/tests/test_manual_payment_requests_api.py deleted file mode 100644 index 6ddcc1b..0000000 --- a/tests/test_manual_payment_requests_api.py +++ /dev/null @@ -1,307 +0,0 @@ -"""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() diff --git a/tests/test_reconciliation_api.py b/tests/test_reconciliation_api.py deleted file mode 100644 index 66757be..0000000 --- a/tests/test_reconciliation_api.py +++ /dev/null @@ -1,294 +0,0 @@ -"""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}" diff --git a/tests/test_settings_auth_api.py b/tests/test_settings_auth_api.py deleted file mode 100644 index b46b156..0000000 --- a/tests/test_settings_auth_api.py +++ /dev/null @@ -1,202 +0,0 @@ -"""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=` 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=` 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=`.""" - 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}" - ) diff --git a/tests/test_settlement_api.py b/tests/test_settlement_api.py deleted file mode 100644 index 442a01e..0000000 --- a/tests/test_settlement_api.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Settlement netting + credit overflow — libra-#33 + libra-#41. - -`POST /libra/api/v1/receivables/settle` with `settled_entry_links=None` -(the default) auto-detects open entries in both directions, builds a -3-leg settlement transaction that zeros out both per-user accounts when -the user has open balances on both sides (libra-#33's nancy scenario), -and routes any excess cash to `Liabilities:Credit:User-X` (libra-#41). - -Underpay without explicit entry-picks returns 400 with diff details so -the operator can either pay the exact net or specify `settled_entry_links`. -""" -import importlib -from uuid import uuid4 - -import pytest - -from .helpers import ( - approve_entry, - get_balance, - list_user_entries, - post_expense, - post_receivable, - settle_receivable, -) - - -def _libra_module(submodule: str): - for prefix in ("lnbits.extensions.libra", "libra"): - try: - return importlib.import_module(f"{prefix}.{submodule}") - except ModuleNotFoundError: - continue - raise ModuleNotFoundError(f"libra.{submodule}") - - -async def _approve_and_refresh(client, wallet, super_user_headers, entry_id): - """Approve a pending entry and force a Fava reload (libra-#37 workaround).""" - await approve_entry(client, super_user_headers=super_user_headers, entry_id=entry_id) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - -# --------------------------------------------------------------------------- -# Nancy's #33 scenario and variants -# --------------------------------------------------------------------------- - - -@pytest.mark.anyio -async def test_exact_net_settlement_zeroes_both_per_user_accounts( - client, super_user_headers, configured_user, standard_accounts, -): - """Nancy: receivable 100 EUR + payable 50 EUR + 50 EUR cash → 3-leg - settlement that zeros both Receivable and Payable for this user. - - Acceptance criteria from libra-#33: - - Settlement links every source entry it reconciles. - - Per-user balances drop to 0 (not just net to 0 leaving each side open). - """ - user, wallet = configured_user - tag = uuid4().hex[:6] - - # Admin records the receivable (cleared on creation). - await post_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="100.00", currency="EUR", - description=f"Rent share {tag}", - revenue_account=standard_accounts["revenue_rent"]["name"], - ) - # User submits an expense (pending until admin approves). - exp = await post_expense( - client, - wallet_inkey=wallet.inkey, - user_wallet_id=wallet.id, - amount="50.00", currency="EUR", - description=f"Drill purchase {tag}", - expense_account=standard_accounts["expense_food"]["name"], - ) - await _approve_and_refresh(client, wallet, super_user_headers, exp["id"]) - - # Sanity check: user owes 50 EUR net (100 receivable - 50 payable). - balance_before = await get_balance(client, wallet_inkey=wallet.inkey) - eur_before = balance_before.get("fiat_balances", {}).get("EUR") - assert float(eur_before) == pytest.approx(50.0), ( - f"expected +50 EUR net (user owes libra), got {eur_before}" - ) - - # Settle the net cash: 50 EUR. - await settle_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="50.00", currency="EUR", - description=f"Cash settlement {tag}", - ) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - # After settlement: net balance is 0. - balance_after = await get_balance(client, wallet_inkey=wallet.inkey) - eur_after = balance_after.get("fiat_balances", {}).get("EUR", 0) - assert float(eur_after or 0) == pytest.approx(0.0), ( - f"expected 0 EUR after exact net settlement, got {eur_after}" - ) - - # Per-account breakdown: every user-side account is at 0. - # (The acceptance criterion is that NEITHER Receivable nor Payable - # carries an open balance — not just that they net to 0.) - breakdown = balance_after.get("account_balances", []) - for row in breakdown: - if user.id[:8] in (row.get("account") or ""): - assert float(row.get("eur", 0) or 0) == pytest.approx(0.0), ( - f"per-user account {row['account']} still has " - f"{row.get('eur')} EUR open after complete settlement; " - f"libra-#33 acceptance criterion violated" - ) - - # The settlement entry's links must cover both source entries. - # Both rcv-* and exp-* links should appear via Fava query. - fava_client_mod = _libra_module("fava_client") - fava = fava_client_mod.get_fava_client() - unsettled_receivables = await fava.get_unsettled_entries_bql(user.id, "receivable") - unsettled_payables = await fava.get_unsettled_entries_bql(user.id, "expense") - assert not unsettled_receivables, ( - f"receivable left as unsettled after complete settlement: " - f"{unsettled_receivables}" - ) - assert not unsettled_payables, ( - f"payable left as unsettled after complete settlement: " - f"{unsettled_payables}" - ) - - -@pytest.mark.anyio -async def test_overpay_routes_excess_to_credit( - client, super_user_headers, configured_user, standard_accounts, -): - """Receivable 100 + payable 50 + cash 70 EUR → settles both per-user - accounts to 0, and the 20 EUR excess lands on Liabilities:Credit:User-X - (libra now owes the user 20 going forward). - - Headline libra-#41 case: cash > net obligation absorbed into credit. - """ - user, wallet = configured_user - tag = uuid4().hex[:6] - - await post_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="100.00", currency="EUR", - description=f"Receivable {tag}", - revenue_account=standard_accounts["revenue_rent"]["name"], - ) - exp = await post_expense( - client, - wallet_inkey=wallet.inkey, - user_wallet_id=wallet.id, - amount="50.00", currency="EUR", - description=f"Payable {tag}", - expense_account=standard_accounts["expense_food"]["name"], - ) - await _approve_and_refresh(client, wallet, super_user_headers, exp["id"]) - - # User pays 70 EUR — 20 EUR over the 50 EUR net obligation. - await settle_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="70.00", currency="EUR", - description=f"Overpay settlement {tag}", - ) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - # Net balance should be -20 EUR (libra owes user 20 via credit). - balance = await get_balance(client, wallet_inkey=wallet.inkey) - eur = balance.get("fiat_balances", {}).get("EUR") - assert float(eur) == pytest.approx(-20.0), ( - f"expected -20 EUR (libra owes user via credit), got {eur} from {balance}" - ) - - # Credit account should appear in the breakdown with -20 EUR. - breakdown = balance.get("account_balances", []) - credit_row = next( - (r for r in breakdown if "Credit" in (r.get("account") or "")), None, - ) - assert credit_row is not None, ( - f"Credit account missing from breakdown: {breakdown}" - ) - assert float(credit_row.get("eur", 0)) == pytest.approx(-20.0), ( - f"expected -20 EUR on Credit:User-X, got {credit_row.get('eur')}" - ) - - -@pytest.mark.anyio -async def test_pure_receivable_overpay_creates_credit( - client, super_user_headers, configured_user, standard_accounts, -): - """No payable side — receivable 50 + cash 70 → receivable cleared, - 20 EUR moves to credit. 2-leg + credit overflow leg.""" - user, wallet = configured_user - tag = uuid4().hex[:6] - - await post_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="50.00", currency="EUR", - description=f"Pure receivable {tag}", - revenue_account=standard_accounts["revenue_rent"]["name"], - ) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - await settle_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="70.00", currency="EUR", - description=f"Pure overpay {tag}", - ) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - balance = await get_balance(client, wallet_inkey=wallet.inkey) - eur = balance.get("fiat_balances", {}).get("EUR") - # Receivable cleared (0) - credit (-20) = -20 net - assert float(eur) == pytest.approx(-20.0), ( - f"expected -20 EUR after pure overpay, got {eur}" - ) - - -# --------------------------------------------------------------------------- -# Validation: underpay without explicit links → 400 with diff -# --------------------------------------------------------------------------- - - -@pytest.mark.anyio -async def test_underpay_without_explicit_links_returns_400( - client, super_user_headers, configured_user, standard_accounts, -): - """Cash < net obligation and no `settled_entry_links` → 400 with the - diff payload so operator can fix the amount or specify entries. - - Without #41's credit overflow + #33's auto-detect, this was the - silent-drift case that motivated both issues. Now: explicit, recoverable. - """ - user, wallet = configured_user - - await post_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="100.00", currency="EUR", - description="Receivable to underpay against", - revenue_account=standard_accounts["revenue_rent"]["name"], - ) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - r = await client.post( - "/libra/api/v1/receivables/settle", - headers=super_user_headers, - json={ - "user_id": user.id, - "amount": "30.00", - "currency": "EUR", - "payment_method": "cash", - "description": "Underpay attempt", - }, - ) - assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" - payload = r.json().get("detail") - assert isinstance(payload, dict), f"expected structured detail, got {payload!r}" - assert payload.get("cash_paid") == 30.0 - assert payload.get("net_obligation") == 100.0 - assert payload.get("receivable_total") == 100.0 - assert payload.get("payable_total") == 0.0 - - -@pytest.mark.anyio -async def test_no_open_receivable_returns_400( - client, super_user_headers, configured_user, -): - """User has no open receivables → endpoint can't settle. 400 with a - hint pointing at `/payables/pay` for the inverse direction.""" - user, _ = configured_user - - r = await client.post( - "/libra/api/v1/receivables/settle", - headers=super_user_headers, - json={ - "user_id": user.id, - "amount": "50.00", - "currency": "EUR", - "payment_method": "cash", - "description": "Random deposit attempt", - }, - ) - assert r.status_code == 400, f"expected 400, got {r.status_code}: {r.text}" - assert "no open receivables" in r.text.lower() or "payables/pay" in r.text - - -# --------------------------------------------------------------------------- -# Legacy explicit-links path: preserved for partial-settle-of-specific-entries -# --------------------------------------------------------------------------- - - -@pytest.mark.anyio -async def test_explicit_settled_entry_links_uses_legacy_2_leg_path( - client, super_user_headers, configured_user, standard_accounts, -): - """When `settled_entry_links` is provided, backend trusts the caller's - list and writes the legacy 2-leg shape. No auto-netting, no credit - overflow validation. Required for callers that want to settle a - specific subset of entries. - - Requires `amount_sats` per the legacy path's existing contract. - """ - user, wallet = configured_user - - await post_receivable( - client, - super_user_headers=super_user_headers, - user_id=user.id, - amount="50.00", currency="EUR", - description="Receivable for explicit-link test", - revenue_account=standard_accounts["revenue_rent"]["name"], - ) - await list_user_entries(client, wallet_inkey=wallet.inkey) - - # Caller passes explicit (but possibly empty) link list → legacy path. - r = await client.post( - "/libra/api/v1/receivables/settle", - headers=super_user_headers, - json={ - "user_id": user.id, - "amount": "50.00", - "currency": "EUR", - "amount_sats": 55_000, - "payment_method": "cash", - "description": "Explicit-link settle", - "settled_entry_links": [], # opts out of auto-detect - }, - ) - assert r.status_code == 200, f"legacy explicit-link path: {r.status_code} {r.text}" diff --git a/tests/test_smoke.py b/tests/test_smoke.py deleted file mode 100644 index 5ee4e2a..0000000 --- a/tests/test_smoke.py +++ /dev/null @@ -1,66 +0,0 @@ -"""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, pending→cleared - 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}" - ) diff --git a/tests/test_unit.py b/tests/test_unit.py deleted file mode 100644 index 1c7dabc..0000000 --- a/tests/test_unit.py +++ /dev/null @@ -1,572 +0,0 @@ -"""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"}) diff --git a/tests/test_void_reject_api.py b/tests/test_void_reject_api.py deleted file mode 100644 index 66e2180..0000000 --- a/tests/test_void_reject_api.py +++ /dev/null @@ -1,212 +0,0 @@ -"""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}" - ) diff --git a/views_api.py b/views_api.py index 1b3149a..8c22f96 100644 --- a/views_api.py +++ b/views_api.py @@ -9,6 +9,7 @@ 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 @@ -52,7 +53,6 @@ from .models import ( LibraSettings, CreateAccount, CreateAccountPermission, - CreateChartAccount, CreateBalanceAssertion, CreateEntryLine, CreateJournalEntry, @@ -62,7 +62,6 @@ from .models import ( CreateUserEquityStatus, ExpenseEntry, GeneratePaymentInvoice, - IncomeEntry, JournalEntry, JournalEntryFlag, ManualPaymentRequest, @@ -434,39 +433,6 @@ 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), @@ -520,6 +486,10 @@ 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", {}) @@ -557,9 +527,18 @@ async def api_get_user_entries( continue # Extract data for frontend - # Resolve canonical entry ID (metadata first, link fallback) - entry_id = _extract_entry_id(e) + # Extract entry ID from links + entry_id = None 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 @@ -616,15 +595,13 @@ async def api_get_user_entries( fiat_amount = float(cost_match.group(1)) fiat_currency = cost_match.group(2) - # 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) + # Extract reference from links (first non-libra 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(_SYSTEM_LINK_PREFIXES): + if not link_clean.startswith("libra-") and not link_clean.startswith("ln-"): reference = link_clean break @@ -804,9 +781,19 @@ 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", []): - # Resolve canonical entry ID (metadata first, link fallback) - entry_id = _extract_entry_id(e) + # Extract entry ID from links field + entry_id = None 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 @@ -922,11 +909,7 @@ 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, - sanitize_link, - ) + from .beancount_format import format_transaction, format_posting_with_cost # Validate that entry balances to zero total = sum(line.amount for line in data.lines) @@ -995,7 +978,7 @@ async def api_create_journal_entry( tags = data.meta.get("tags", []) links = data.meta.get("links", []) if data.reference: - links.append(sanitize_link(data.reference)) + links.append(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"]} @@ -1148,7 +1131,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 + from .beancount_format import format_expense_entry, sanitize_link fava = get_fava_client() @@ -1160,8 +1143,12 @@ async def api_create_expense_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Format Beancount entry. Identity travels as entry-id metadata + - # exp-{entry_id} link; the user reference becomes its own link. + # 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 entry = format_expense_entry( user_id=wallet.wallet.user, expense_account=expense_account.name, @@ -1172,8 +1159,8 @@ async def api_create_expense_entry( is_equity=data.is_equity, fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id + reference=libra_reference, + entry_id=entry_id # Pass entry_id so all links match ) # Submit to Fava @@ -1187,7 +1174,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=data.reference, + reference=libra_reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1211,140 +1198,10 @@ async def api_create_expense_entry( ) -@libra_api_router.post("/api/v1/entries/income", status_code=HTTPStatus.CREATED) -async def api_create_income_entry( - data: IncomeEntry, - wallet: WalletTypeInfo = Depends(require_invoice_key), -) -> JournalEntry: - """ - Create a user-submitted income/revenue entry (pending approval). - - Mirrors the expense submission flow: entry is created with '!' (pending) - flag and goes through the same /api/v1/entries/{id}/approve and /reject - endpoints. Requires SUBMIT_INCOME permission on the revenue account. - - Postings: DR payment_method_account (asset), CR revenue_account. - """ - # Validate currency - if data.currency.upper() not in allowed_currencies(): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Currency '{data.currency}' not allowed. Use one of: {', '.join(allowed_currencies())}", - ) - - # Resolve revenue account by name or ID - revenue_account = await get_account_by_name(data.revenue_account) - if not revenue_account: - revenue_account = await get_account(data.revenue_account) - if not revenue_account: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Revenue account '{data.revenue_account}' not found", - ) - if revenue_account.account_type != AccountType.REVENUE: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Account '{revenue_account.name}' is not a revenue account (type: {revenue_account.account_type.value})", - ) - - # Permission check on the revenue account - from .crud import get_user_permissions_with_inheritance - - submit_perms = await get_user_permissions_with_inheritance( - wallet.wallet.user, revenue_account.name, PermissionType.SUBMIT_INCOME - ) - if not submit_perms: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=f"You do not have permission to submit income to account '{revenue_account.name}'. Please contact an administrator to request access.", - ) - - # Income lands on the user as a receivable — they're holding cash on - # behalf of the entity until they hand it over via /settle-receivable. - user_account = await get_or_create_user_account( - wallet.wallet.user, AccountType.ASSET, "Accounts Receivable" - ) - - # Convert fiat to sats - fiat_currency = data.currency.upper() - amount_sats = await fiat_amount_as_satoshis(float(data.amount), data.currency) - - metadata = { - "fiat_currency": fiat_currency, - "fiat_amount": str(data.amount.quantize(Decimal("0.001"))), - "fiat_rate": float(amount_sats) / float(data.amount) if data.amount > 0 else 0, - "btc_rate": float(data.amount) / float(amount_sats) * 100_000_000 if amount_sats > 0 else 0, - } - - # Submit to Fava - from .fava_client import get_fava_client - from .beancount_format import format_income_entry - - fava = get_fava_client() - - import uuid - entry_id = str(uuid.uuid4()).replace("-", "")[:16] - - # 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, - revenue_account=revenue_account.name, - amount_sats=amount_sats, - description=data.description, - entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), - fiat_currency=fiat_currency, - fiat_amount=data.amount, - reference=data.reference, - entry_id=entry_id, - ) - - result = await fava.add_entry(entry) - logger.info(f"Income entry {entry_id} submitted to Fava (pending): {result.get('data', 'Unknown')}") - - description_suffix = f" ({metadata['fiat_amount']} {fiat_currency})" - entry_meta = { - "source": "api", - "created_via": "income_entry", - "user_id": wallet.wallet.user, - } - - from .models import EntryLine - return JournalEntry( - id=entry_id, - description=data.description + description_suffix, - entry_date=data.entry_date if data.entry_date else datetime.now(), - created_by=wallet.wallet.user, - created_at=datetime.now(), - reference=data.reference, - flag=JournalEntryFlag.PENDING, - meta=entry_meta, - lines=[ - EntryLine( - id=f"line-1-{entry_id}", - journal_entry_id=entry_id, - account_id=user_account.id, - amount=amount_sats, - description=f"User holds cash receivable to entity ({user_account.name})", - metadata=metadata, - ), - EntryLine( - id=f"line-2-{entry_id}", - journal_entry_id=entry_id, - account_id=revenue_account.id, - amount=-amount_sats, - description="Revenue earned (pending approval)", - metadata=metadata, - ), - ], - ) - - @libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) async def api_create_receivable_entry( data: ReceivableEntry, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> JournalEntry: """ Create an accounts receivable entry (user owes libra). @@ -1403,7 +1260,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 + from .beancount_format import format_receivable_entry, sanitize_link fava = get_fava_client() @@ -1415,8 +1272,12 @@ async def api_create_receivable_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Format Beancount entry. Identity travels as entry-id metadata + - # rcv-{entry_id} link; the user reference becomes its own link. + # 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 entry = format_receivable_entry( user_id=data.user_id, revenue_account=revenue_account.name, @@ -1426,8 +1287,8 @@ async def api_create_receivable_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id + reference=libra_reference, + entry_id=entry_id # Pass entry_id so all links match ) # Submit to Fava @@ -1439,9 +1300,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=auth.user_id, + created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), - reference=data.reference, + reference=libra_reference, # Use libra reference with unique ID flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1468,7 +1329,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> JournalEntry: """ Create a revenue entry (libra receives payment). @@ -1477,7 +1338,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 + from .beancount_format import format_revenue_entry, sanitize_link # Get revenue account revenue_account = await get_account_by_name(data.revenue_account) @@ -1527,8 +1388,11 @@ async def api_create_revenue_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Identity travels as entry-id metadata; the user reference becomes - # its own link. + # 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}" + entry = format_revenue_entry( payment_account=payment_account.name, revenue_account=revenue_account.name, @@ -1537,8 +1401,7 @@ async def api_create_revenue_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=data.reference, - entry_id=entry_id, + reference=libra_reference # Use libra reference with unique ID ) # Submit to Fava @@ -1551,9 +1414,9 @@ async def api_create_revenue_entry( id=entry_id, description=data.description, entry_date=datetime.now(), - created_by=auth.user_id, + created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), - reference=data.reference, + reference=libra_reference, flag=JournalEntryFlag.CLEARED, lines=[], # Empty - entry is stored in Fava, not Libra DB meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} @@ -1595,35 +1458,22 @@ async def api_get_my_balance( # Add all balances (positive and negative) total_fiat_balances[currency] += amount - # Super-user totals reflect their personal submissions (if any), not org-wide - super_totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user) - # Return net position return UserBalance( user_id=wallet.wallet.user, balance=net_balance, accounts=[], fiat_balances=total_fiat_balances, - total_expenses_sats=super_totals["total_expenses_sats"], - total_expenses_fiat=super_totals["total_expenses_fiat"], - total_income_sats=super_totals["total_income_sats"], - total_income_fiat=super_totals["total_income_fiat"], ) # For regular users, show their individual balance from Fava balance_data = await fava.get_user_balance_bql(wallet.wallet.user) - totals = await fava.get_user_lifetime_totals_bql(wallet.wallet.user) return UserBalance( user_id=wallet.wallet.user, balance=balance_data["balance"], - accounts=[], - account_balances=balance_data.get("accounts", []), + accounts=[], # Could populate from balance_data["accounts"] if needed fiat_balances=balance_data["fiat_balances"], - total_expenses_sats=totals["total_expenses_sats"], - total_expenses_fiat=totals["total_expenses_fiat"], - total_income_sats=totals["total_income_sats"], - total_income_fiat=totals["total_income_fiat"], ) @@ -1644,17 +1494,12 @@ async def api_get_user_balance( fava = get_fava_client() balance_data = await fava.get_user_balance_bql(user_id) - totals = await fava.get_user_lifetime_totals_bql(user_id) return UserBalance( user_id=user_id, balance=balance_data["balance"], accounts=[], fiat_balances=balance_data["fiat_balances"], - total_expenses_sats=totals["total_expenses_sats"], - total_expenses_fiat=totals["total_expenses_fiat"], - total_income_sats=totals["total_income_sats"], - total_income_fiat=totals["total_income_fiat"], ) @@ -1939,6 +1784,65 @@ 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, @@ -2000,11 +1904,7 @@ 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, - format_fiat_net_settlement_entry, - ) + from .beancount_format import format_payment_entry, format_fiat_settlement_entry from decimal import Decimal fava = get_fava_client() @@ -2014,106 +1914,9 @@ async def api_settle_receivable( "cash", "bank_transfer", "check", "other" ] - 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 is_fiat_payment: + # Fiat currency payment (cash, bank transfer, etc.) + # Record in fiat currency with sats as metadata if not data.amount_sats: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -2166,7 +1969,7 @@ async def api_settle_receivable( if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method - entry["meta"]["settled-by"] = auth.user_id + entry["meta"]["settled-by"] = wallet.wallet.user if data.txid: entry["meta"]["txid"] = data.txid @@ -2540,7 +2343,7 @@ async def api_expense_report( @libra_api_router.get("/api/v1/reports/contributions") async def api_contributions_report( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Get user contribution report using BQL. @@ -2609,7 +2412,7 @@ async def api_contributions_report( async def api_get_unsettled_entries( user_id: str, entry_type: str = "expense", - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Get unsettled expense or receivable entries for a user. @@ -2817,7 +2620,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, auth.user_id, entry_id + request_id, wallet.wallet.user, entry_id ) @@ -2865,14 +2668,21 @@ async def api_approve_expense_entry( # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() - # 2. Find the pending transaction with matching canonical entry id + # 2. Find the entry with matching libra ID in links target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": - if _extract_entry_id(entry) == entry_id: - target_entry = entry + 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: break if not target_entry: @@ -2974,14 +2784,21 @@ async def api_reject_expense_entry( # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() - # 2. Find the pending transaction with matching canonical entry id + # 2. Find the entry with matching libra ID in links target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": - if _extract_entry_id(entry) == entry_id: - target_entry = entry + 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: break if not target_entry: @@ -3377,18 +3194,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> 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, auth.user_id) + return await create_or_update_user_equity_status(data, wallet.wallet.user) @libra_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}") async def api_revoke_equity_eligibility( user_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> UserEquityStatus: """Revoke equity contribution eligibility from a user (admin only)""" from .crud import revoke_user_equity_eligibility @@ -3404,7 +3221,7 @@ async def api_revoke_equity_eligibility( @libra_api_router.get("/api/v1/admin/equity-eligibility") async def api_list_equity_eligible_users( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[UserEquityStatus]: """List all equity-eligible users (admin only)""" from .crud import get_all_equity_eligible_users @@ -3418,7 +3235,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> AccountPermission: """Grant account permission to a user (admin only)""" # Validate that account exists @@ -3429,14 +3246,14 @@ async def api_grant_permission( detail=f"Account with ID '{data.account_id}' not found", ) - return await create_account_permission(data, auth.user_id) + return await create_account_permission(data, wallet.wallet.user) @libra_api_router.get("/api/v1/admin/permissions") async def api_list_permissions( user_id: str | None = None, account_id: str | None = None, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[AccountPermission]: """ List account permissions (admin only). @@ -3469,7 +3286,7 @@ async def api_list_permissions( @libra_api_router.delete("/api/v1/admin/permissions/{permission_id}") async def api_revoke_permission( permission_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """Revoke (delete) an account permission (admin only)""" # Verify permission exists @@ -3491,7 +3308,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], - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list[AccountPermission]: """Grant multiple account permissions at once (admin only)""" created_permissions = [] @@ -3505,7 +3322,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, auth.user_id) + perm = await create_account_permission(perm_data, wallet.wallet.user) created_permissions.append(perm) return created_permissions @@ -3514,7 +3331,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", - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> "BulkGrantResult": """ Grant the same permission to multiple users at once (admin only). @@ -3548,7 +3365,7 @@ async def api_bulk_grant_permission_to_users( expires_at=data.expires_at, notes=data.notes, ) - perm = await create_account_permission(perm_data, auth.user_id) + perm = await create_account_permission(perm_data, wallet.wallet.user) granted.append(perm) except Exception as e: failed.append({ @@ -3658,136 +3475,10 @@ 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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Sync all accounts from Beancount to Libra DB (admin only). @@ -3803,7 +3494,7 @@ async def api_sync_all_accounts( """ from .account_sync import sync_accounts_from_beancount - logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})") + logger.info(f"Admin {wallet.wallet.user[:8]} triggered account sync (force={force_full_sync})") try: stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync) @@ -3820,7 +3511,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> dict: """ Sync a single account from Beancount to Libra DB (admin only). @@ -3836,7 +3527,7 @@ async def api_sync_single_account( """ from .account_sync import sync_single_account_from_beancount - logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}") + logger.info(f"Admin {wallet.wallet.user[:8]} triggered sync for account: {account_name}") try: created = await sync_single_account_from_beancount(account_name) @@ -3866,7 +3557,7 @@ async def api_sync_single_account( @libra_api_router.get("/api/v1/admin/roles") async def api_get_all_roles( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ) -> list: """Get all roles (admin only)""" from . import crud @@ -3896,13 +3587,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Create a new role (admin only)""" from . import crud try: - role = await crud.create_role(data, created_by=auth.user_id) + role = await crud.create_role(data, created_by=wallet.wallet.user) return { "id": role.id, "name": role.name, @@ -3922,7 +3613,7 @@ async def api_create_role( @libra_api_router.get("/api/v1/admin/roles/{role_id}") async def api_get_role( role_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Get a specific role with its permissions and users (admin only)""" from . import crud @@ -3972,7 +3663,7 @@ async def api_get_role( async def api_update_role( role_id: str, data: UpdateRole, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Update a role (admin only)""" from . import crud @@ -3997,7 +3688,7 @@ async def api_update_role( @libra_api_router.delete("/api/v1/admin/roles/{role_id}") async def api_delete_role( role_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Delete a role (admin only) - cascades to role_permissions and user_roles""" from . import crud @@ -4020,7 +3711,7 @@ async def api_delete_role( async def api_add_role_permission( role_id: str, data: CreateRolePermission, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Add a permission to a role (admin only)""" from . import crud @@ -4058,7 +3749,7 @@ async def api_add_role_permission( async def api_delete_role_permission( role_id: str, permission_id: str, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Remove a permission from a role (admin only)""" from . import crud @@ -4073,7 +3764,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Assign a user to a role (admin only)""" from . import crud @@ -4087,7 +3778,7 @@ async def api_assign_user_role( ) try: - user_role = await crud.assign_user_role(data, granted_by=auth.user_id) + user_role = await crud.assign_user_role(data, granted_by=wallet.wallet.user) return { "id": user_role.id, "user_id": user_role.user_id, @@ -4108,7 +3799,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Get all roles assigned to a user (admin only)""" from . import crud @@ -4141,7 +3832,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, - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Revoke a user's role assignment (admin only)""" from . import crud @@ -4152,7 +3843,7 @@ async def api_revoke_user_role( @libra_api_router.get("/api/v1/admin/users/roles") async def api_get_all_user_roles( - auth: AuthContext = Depends(require_super_user), + wallet: WalletTypeInfo = Depends(require_admin_key), ): """Get all user role assignments (admin only)""" from . import crud