diff --git a/CLAUDE.md b/CLAUDE.md index 97e546f..77bcd65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,8 +209,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 +217,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 +278,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 +306,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/beancount_format.py b/beancount_format.py index f233ee5..486ad57 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -945,8 +945,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 +962,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 +978,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 +1002,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, 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..c61e1d9 100644 --- a/fava_client.py +++ b/fava_client.py @@ -44,55 +44,6 @@ def _infer_target_file(account_name: str) -> str: 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. @@ -1593,7 +1544,7 @@ 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, @@ -1676,16 +1627,10 @@ 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): + # Step 2: 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 {target_file}") - return { - "data": sha256sum, - "mtime": source_data.get("mtime", ""), - "already_existed": True, - } + 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 @@ -1697,23 +1642,19 @@ class FavaClient: lines = source.split('\n') insert_index = len(lines) - # 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 4: 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}') @@ -1739,7 +1680,7 @@ class FavaClient: result = response.json() logger.info(f"Added account {account_name} to {target_file} with currencies {currencies}") - return {**result, "already_existed": False} + return result except httpx.HTTPStatusError as e: # Check for checksum conflict (HTTP 412 Precondition Failed or similar) diff --git a/models.py b/models.py index c8632d0..8be64c9 100644 --- a/models.py +++ b/models.py @@ -51,11 +51,7 @@ class CreateAccount(BaseModel): 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 + currencies: list[str] = ["EUR", "SATS", "USD"] description: Optional[str] = None diff --git a/static/js/index.js b/static/js/index.js index 6f451f7..418ec41 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' diff --git a/templates/libra/index.html b/templates/libra/index.html index 5369f72..0de0e71 100644 --- a/templates/libra/index.html +++ b/templates/libra/index.html @@ -857,20 +857,7 @@ -
-
Chart of Accounts
- - -
+
Chart of Accounts
@@ -1245,63 +1232,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 index 4c6c30b..efdab4b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -22,56 +22,22 @@ 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: +From the LNbits source root (with the libra extension reachable via `LNBITS_EXTENSIONS_PATH` or symlinked into `lnbits/extensions/`): ```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" +# Whole suite +pytest path/to/libra/tests -# 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 +pytest path/to/libra/tests/test_smoke.py # One area -... pytest path/to/libra/tests/test_balances_api.py +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 +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 diff --git a/tests/conftest.py b/tests/conftest.py index 44b5c26..6698018 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,9 +108,6 @@ def _settings_cleanup(settings: Settings) -> None: 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") @@ -136,12 +133,6 @@ def settings() -> Iterator[Settings]: 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 @@ -179,32 +170,13 @@ option "render_commas" "TRUE" 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).""" + """Session-scoped .beancount file Fava reads from.""" 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 diff --git a/tests/helpers.py b/tests/helpers.py index 02d8f78..4bc0105 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -13,7 +13,7 @@ separate ISO code field — this matches `models.ExpenseEntry` / `ReceivableEntr from decimal import Decimal from typing import Any, Optional, Union -from httpx import AsyncClient, Response +from httpx import AsyncClient Amount = Union[Decimal, int, float, str] @@ -106,26 +106,6 @@ async def grant_permission( 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 # --------------------------------------------------------------------------- @@ -141,7 +121,6 @@ async def post_expense( 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. @@ -157,7 +136,6 @@ async def post_expense( "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}" @@ -172,7 +150,6 @@ async def post_income( 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( @@ -183,14 +160,13 @@ async def post_income( "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]: +async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[dict]: r = await client.get( "/libra/api/v1/entries/user", headers={"X-Api-Key": wallet_inkey}, @@ -199,18 +175,6 @@ async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> dict[s 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 # --------------------------------------------------------------------------- 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_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_unit.py b/tests/test_unit.py index 1c7dabc..bb74a9c 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -31,75 +31,9 @@ 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 # --------------------------------------------------------------------------- @@ -201,96 +135,6 @@ def test_generate_entry_ids_are_unique(): 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 # --------------------------------------------------------------------------- diff --git a/views_api.py b/views_api.py index 1b3149a..fb46809 100644 --- a/views_api.py +++ b/views_api.py @@ -434,39 +434,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), @@ -557,9 +524,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 +592,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 +778,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 +906,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 +975,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 +1128,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 +1140,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 +1156,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 +1171,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=[ @@ -1278,15 +1262,17 @@ async def api_create_income_entry( # Submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_income_entry + from .beancount_format import format_income_entry, sanitize_link 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. + libra_reference = f"libra-{entry_id}" + if data.reference: + libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" + entry = format_income_entry( user_id=wallet.wallet.user, user_account=user_account.name, @@ -1296,7 +1282,7 @@ async def api_create_income_entry( entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=data.amount, - reference=data.reference, + reference=libra_reference, entry_id=entry_id, ) @@ -1317,7 +1303,7 @@ async def api_create_income_entry( entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.user, created_at=datetime.now(), - reference=data.reference, + reference=libra_reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1403,7 +1389,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 +1401,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 +1416,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 @@ -1441,7 +1431,7 @@ async def api_create_receivable_entry( entry_date=datetime.now(), created_by=auth.user_id, created_at=datetime.now(), - reference=data.reference, + reference=libra_reference, # Use libra reference with unique ID flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1477,7 +1467,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 +1517,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 +1530,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 @@ -1553,7 +1545,7 @@ async def api_create_revenue_entry( entry_date=datetime.now(), created_by=auth.user_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')} @@ -2865,14 +2857,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 +2973,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: @@ -3661,52 +3667,6 @@ async def api_get_account_hierarchy( _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, @@ -3731,8 +3691,6 @@ async def api_admin_add_chart_account( ), ) - _validate_account_name(payload.name) - logger.info( f"Admin {auth.user_id[:8]} adding chart account {payload.name} " f"with currencies {payload.currencies}" @@ -3743,38 +3701,15 @@ async def api_admin_add_chart_account( if payload.description: metadata["description"] = payload.description - result = await fava.add_account( + 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. + from .account_sync import sync_single_account_from_beancount synced = await sync_single_account_from_beancount(payload.name) return {