From 15d991007344b904578b067b1c827ea921794be7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 20:39:06 +0200 Subject: [PATCH] Resolve entry identity via entry-id metadata; unfuse user references (libra-#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approving a pending entry created with a reference (e.g. invoice "42-144") 404'd with "Pending entry unknown not found": the list endpoints recovered the entry id by parsing links for a libra- prefix, but reference-bearing entries displace that link with the fused "{reference}-{entry_id}" form, so the id surfaced as the literal "unknown" and the approve call round-tripped it. Make the entry-id transaction metadata the single canonical identity: - _extract_entry_id() resolves metadata-first (libra- link parsing kept only for pre-dfdcc44 ledger history); used by /entries/user, /entries/pending, approve, and reject. - Creation endpoints no longer fuse the reference with the entry id — the user reference becomes its own sanitized link and round-trips verbatim in API responses. Typed exp-/rcv-/inc- links stay as the settlement-tracking handles. - format_revenue_entry now writes entry-id metadata like its siblings and sanitizes its reference link (was appended raw); generic POST /entries sanitizes its reference link too. - User-journal reference extraction skips all system link prefixes (typed links used to leak into the reference field). Contract documented in CLAUDE.md (Data Integrity → Entry Identity & Links), pinned by tests/test_entry_identity_api.py and formatter contract tests in test_unit.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 15 ++- beancount_format.py | 14 ++- tests/helpers.py | 18 +++- tests/test_entry_identity_api.py | 168 +++++++++++++++++++++++++++++++ tests/test_unit.py | 90 +++++++++++++++++ views_api.py | 158 ++++++++++++++--------------- 6 files changed, 374 insertions(+), 89 deletions(-) create mode 100644 tests/test_entry_identity_api.py diff --git a/CLAUDE.md b/CLAUDE.md index 77bcd65..97e546f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,8 @@ entry = format_transaction( {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} ], tags=["groceries"], - links=["libra-entry-123"] + links=["exp-a1b2c3d4e5f60708"], # typed settlement link; identity goes in entry-id metadata + meta={"entry-id": "a1b2c3d4e5f60708"} ) # Submit to Fava @@ -217,6 +218,8 @@ client = get_fava_client() result = await client.add_entry(entry) ``` +Prefer the purpose-built formatters (`format_expense_entry`, `format_income_entry`, …) over raw `format_transaction` — they write the `entry-id` metadata and typed links for you (see Data Integrity → Entry Identity & Links). + **Querying Balances**: ```python # Query user balance from Fava @@ -278,7 +281,8 @@ entry = format_transaction( {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} ], tags=["utilities"], - links=["libra-tx-123"] + links=["exp-0123456789abcdef"], + meta={"entry-id": "0123456789abcdef"} ) client = get_fava_client() @@ -306,6 +310,13 @@ result = await client.query(query) 3. User accounts use `user_id` (NOT `wallet_id`) for consistency 4. All accounting calculations delegated to Beancount/Fava +**Entry Identity & Links** (the contract `_extract_entry_id()` in views_api.py relies on): +- The `entry-id` **transaction metadata** is the single canonical entry identifier. Every libra-authored entry formatter (`format_expense_entry`, `format_receivable_entry`, `format_income_entry`, `format_revenue_entry`) writes it. All id resolution (pending list, user journal, approve, reject) reads it — never parse links to recover an id. +- Typed links `exp-{id}` / `rcv-{id}` / `inc-{id}` exist for **settlement tracking** (BQL queries match settlements to source entries by these links). They duplicate the id but are not the identity source. +- A user-supplied `reference` (invoice number, receipt id) becomes **its own sanitized link**, verbatim — never fused with the entry id, never displacing a system link. Two entries sharing a reference share the link (desired Beancount semantics). +- `ln-{payment_hash[:16]}` links mark Lightning payments. +- Legacy ledger history (pre-dfdcc44) carries a single `libra-{id}` link and no `entry-id` metadata — `_extract_entry_id()` falls back to parsing it. Do not write `libra-` links in new code. + **Validation** is performed in `core/validation.py`: - Pure validation functions for entry correctness before submitting to Fava diff --git a/beancount_format.py b/beancount_format.py index 486ad57..f233ee5 100644 --- a/beancount_format.py +++ b/beancount_format.py @@ -945,7 +945,8 @@ def format_revenue_entry( entry_date: date, fiat_currency: Optional[str] = None, fiat_amount: Optional[Decimal] = None, - reference: Optional[str] = None + reference: Optional[str] = None, + entry_id: Optional[str] = None ) -> Dict[str, Any]: """ Format a revenue entry (libra receives payment directly). @@ -962,7 +963,8 @@ def format_revenue_entry( entry_date: Date of payment fiat_currency: Optional fiat currency fiat_amount: Optional fiat amount (unsigned) - reference: Optional reference + reference: Optional reference (invoice ID, etc.) — stored as its own link + entry_id: Optional unique entry ID (generated if not provided) Returns: Fava API entry dict @@ -978,6 +980,9 @@ def format_revenue_entry( fiat_amount=Decimal("50.00") ) """ + if not entry_id: + entry_id = generate_entry_id() + amount_sats_abs = abs(amount_sats) fiat_amount_abs = abs(fiat_amount) if fiat_amount else None @@ -1002,12 +1007,13 @@ def format_revenue_entry( # Note: created-via is redundant with #revenue-entry tag entry_meta = { - "source": "libra-api" + "source": "libra-api", + "entry-id": entry_id } links = [] if reference: - links.append(reference) + links.append(sanitize_link(reference)) return format_transaction( date_val=entry_date, diff --git a/tests/helpers.py b/tests/helpers.py index 4bc0105..80ad343 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -121,6 +121,7 @@ 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. @@ -136,6 +137,7 @@ 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}" @@ -150,6 +152,7 @@ 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( @@ -160,13 +163,14 @@ 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) -> list[dict]: +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}, @@ -175,6 +179,18 @@ async def list_user_entries(client: AsyncClient, *, wallet_inkey: str) -> list[d 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_entry_identity_api.py b/tests/test_entry_identity_api.py new file mode 100644 index 0000000..2b893ca --- /dev/null +++ b/tests/test_entry_identity_api.py @@ -0,0 +1,168 @@ +"""Entry identity resolution — the canonical id must survive a user reference. + +Regression coverage for the production bug where a pending income entry +created with a `reference` (e.g. an invoice number like "42-144") could +not be approved: the admin UI's pending list resolved the entry id by +parsing links for a `libra-` prefix, but reference-bearing entries carry +typed links (`inc-/exp-/rcv-{id}`) plus the reference as its own link — +no `libra-` link. The id surfaced as the literal string "unknown" and +`POST /entries/unknown/approve` 404'd. + +The fix makes the `entry-id` transaction metadata the single source of +truth (list, approve, and reject endpoints), with link parsing kept only +for pre-metadata ledger history. These tests pin that contract: + + - pending list returns the real id for reference-bearing entries + - approve/reject resolve that id end-to-end + - the user reference round-trips as `reference`, never as a system link +""" +from uuid import uuid4 + +import pytest + +from .helpers import ( + approve_entry, + list_pending_entries, + list_user_entries, + post_expense, + post_income, + reject_entry, +) + + +@pytest.mark.anyio +async def test_pending_income_with_reference_resolves_real_id( + client, super_user_headers, configured_user, standard_accounts, +): + """The production repro: income + reference must list with its real + id (not 'unknown') and approve successfully.""" + _, wallet = configured_user + marker = f"Membership dues {uuid4().hex[:6]}" + + posted = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="700.00", currency="EUR", + description=marker, + revenue_account=standard_accounts["revenue_rent"]["name"], + reference="42-144", + ) + + pending = await list_pending_entries( + client, super_user_headers=super_user_headers, + ) + entry = next( + (e for e in pending if marker in (e.get("description") or "")), None, + ) + assert entry is not None, f"income entry not in pending list: {pending}" + assert entry["id"] == posted["id"], ( + f"pending list must surface the canonical entry id, " + f"got {entry['id']!r} (expected {posted['id']!r})" + ) + assert entry["id"] != "unknown" + + # The id from the listing must drive approval end-to-end. + result = await approve_entry( + client, super_user_headers=super_user_headers, entry_id=entry["id"], + ) + assert result.get("entry_id") == posted["id"] + + +@pytest.mark.anyio +async def test_pending_expense_with_reference_resolves_real_id_and_rejects( + client, super_user_headers, configured_user, standard_accounts, +): + """Same contract on the expense path, exercised through reject.""" + _, wallet = configured_user + marker = f"Receipted groceries {uuid4().hex[:6]}" + + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="36.93", currency="EUR", + description=marker, + expense_account=standard_accounts["expense_food"]["name"], + reference="RECEIPT/2026-06-12", + ) + + pending = await list_pending_entries( + client, super_user_headers=super_user_headers, + ) + entry = next( + (e for e in pending if marker in (e.get("description") or "")), None, + ) + assert entry is not None, f"expense entry not in pending list: {pending}" + assert entry["id"] == posted["id"] + + result = await reject_entry( + client, super_user_headers=super_user_headers, entry_id=entry["id"], + ) + assert result.get("entry_id") == posted["id"] + + +@pytest.mark.anyio +async def test_reference_round_trips_in_user_journal( + client, configured_user, standard_accounts, +): + """The user journal must report the user's reference, not a system + link (typed inc-/exp- links used to leak into the reference field).""" + _, wallet = configured_user + marker = f"Referenced expense {uuid4().hex[:6]}" + + posted = await post_expense( + client, + wallet_inkey=wallet.inkey, + user_wallet_id=wallet.id, + amount="12.00", currency="EUR", + description=marker, + expense_account=standard_accounts["expense_food"]["name"], + reference="INV-7731", + ) + assert posted.get("reference") == "INV-7731" + + listing = await list_user_entries(client, wallet_inkey=wallet.inkey) + entry = next( + ( + e for e in listing.get("entries", []) + if marker in (e.get("description") or "") + ), + None, + ) + assert entry is not None + assert entry["id"] == posted["id"] + assert entry.get("reference") == "INV-7731", ( + f"reference field must carry the user's reference, " + f"got {entry.get('reference')!r}" + ) + + +@pytest.mark.anyio +async def test_entry_without_reference_still_resolves( + client, super_user_headers, configured_user, standard_accounts, +): + """No-reference entries keep working (the case that always worked).""" + _, wallet = configured_user + marker = f"Plain income {uuid4().hex[:6]}" + + posted = await post_income( + client, + wallet_inkey=wallet.inkey, + amount="55.00", currency="EUR", + description=marker, + revenue_account=standard_accounts["revenue_rent"]["name"], + ) + + pending = await list_pending_entries( + client, super_user_headers=super_user_headers, + ) + entry = next( + (e for e in pending if marker in (e.get("description") or "")), None, + ) + assert entry is not None + assert entry["id"] == posted["id"] + + result = await approve_entry( + client, super_user_headers=super_user_headers, entry_id=entry["id"], + ) + assert result.get("entry_id") == posted["id"] diff --git a/tests/test_unit.py b/tests/test_unit.py index bb74a9c..2fa41ec 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -135,6 +135,96 @@ 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 fb46809..7dce7c3 100644 --- a/views_api.py +++ b/views_api.py @@ -434,6 +434,39 @@ async def api_get_journal_entries( return enriched_entries +# Link prefixes written by libra itself (vs user-supplied references): +# exp-/rcv-/inc- typed entry links, ln- lightning payment links, and the +# legacy libra-{id} identity link. +_SYSTEM_LINK_PREFIXES = ("exp-", "rcv-", "inc-", "ln-", "libra-") + + +def _extract_entry_id(entry: dict) -> Optional[str]: + """Resolve the canonical libra entry id for a Fava transaction. + + The ``entry-id`` transaction metadata is the single source of truth — + written by every libra entry formatter since dfdcc44. Ledger history + predating it carries only a ``libra-{id}`` link; parse that as a + fallback so old entries still resolve. + + Returns None when no id can be determined (e.g. settlement/payment + transactions, which are not approvable). + """ + meta = entry.get("meta", {}) + entry_id = meta.get("entry-id") + if entry_id: + return str(entry_id) + + # Legacy fallback: pre-entry-id ledger history (single libra-{id} link) + links = entry.get("links", []) + if isinstance(links, (list, set)): + for link in links: + if isinstance(link, str): + link_clean = link.lstrip('^') + if link_clean.startswith("libra-"): + return link_clean[len("libra-"):] + return None + + @libra_api_router.get("/api/v1/entries/user") async def api_get_user_entries( wallet: WalletTypeInfo = Depends(require_invoice_key), @@ -524,18 +557,9 @@ async def api_get_user_entries( continue # Extract data for frontend - # Extract entry ID from links - entry_id = None + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) links = e.get("links", []) - if isinstance(links, (list, set)): - for link in links: - if isinstance(link, str): - link_clean = link.lstrip('^') - if "libra-" in link_clean: - parts = link_clean.split("libra-") - if len(parts) > 1: - entry_id = parts[-1] - break # Extract amount from postings amount_sats = 0 @@ -592,13 +616,15 @@ async def api_get_user_entries( fiat_amount = float(cost_match.group(1)) fiat_currency = cost_match.group(2) - # Extract reference from links (first non-libra link) + # Extract reference from links (first link that isn't a + # libra-system link: typed entry/settlement links, lightning + # payment links, or the legacy libra-{id} identity link) reference = None if isinstance(links, (list, set)): for link in links: if isinstance(link, str): link_clean = link.lstrip('^') - if not link_clean.startswith("libra-") and not link_clean.startswith("ln-"): + if not link_clean.startswith(_SYSTEM_LINK_PREFIXES): reference = link_clean break @@ -778,19 +804,9 @@ async def api_get_pending_entries( for e in all_entries: # Only include pending transactions that are NOT voided if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): - # Extract entry ID from links field - entry_id = None + # Resolve canonical entry ID (metadata first, link fallback) + entry_id = _extract_entry_id(e) links = e.get("links", []) - if isinstance(links, (list, set)): - for link in links: - if isinstance(link, str): - # Strip ^ prefix if present (Beancount link syntax) - link_clean = link.lstrip('^') - if "libra-" in link_clean: - parts = link_clean.split("libra-") - if len(parts) > 1: - entry_id = parts[-1] - break # Extract user ID from metadata or account names user_id = None @@ -906,7 +922,11 @@ async def api_create_journal_entry( Submits entry to Fava/Beancount. """ from .fava_client import get_fava_client - from .beancount_format import format_transaction, format_posting_with_cost + from .beancount_format import ( + format_transaction, + format_posting_with_cost, + sanitize_link, + ) # Validate that entry balances to zero total = sum(line.amount for line in data.lines) @@ -975,7 +995,7 @@ async def api_create_journal_entry( tags = data.meta.get("tags", []) links = data.meta.get("links", []) if data.reference: - links.append(data.reference) + links.append(sanitize_link(data.reference)) # Entry metadata (excluding tags and links which go at transaction level) entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]} @@ -1128,7 +1148,7 @@ async def api_create_expense_entry( # Format as Beancount entry and submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_expense_entry, sanitize_link + from .beancount_format import format_expense_entry fava = get_fava_client() @@ -1140,12 +1160,8 @@ async def api_create_expense_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Add libra ID as reference/link (sanitized for Beancount) - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - - # Format Beancount entry + # Format Beancount entry. Identity travels as entry-id metadata + + # exp-{entry_id} link; the user reference becomes its own link. entry = format_expense_entry( user_id=wallet.wallet.user, expense_account=expense_account.name, @@ -1156,8 +1172,8 @@ async def api_create_expense_entry( is_equity=data.is_equity, fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=libra_reference, - entry_id=entry_id # Pass entry_id so all links match + reference=data.reference, + entry_id=entry_id ) # Submit to Fava @@ -1171,7 +1187,7 @@ async def api_create_expense_entry( entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.user, # Use user_id, not wallet_id created_at=datetime.now(), - reference=libra_reference, + reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1262,17 +1278,15 @@ async def api_create_income_entry( # Submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_income_entry, sanitize_link + from .beancount_format import format_income_entry fava = get_fava_client() import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - + # Identity travels as entry-id metadata + inc-{entry_id} link; the + # user reference becomes its own link. entry = format_income_entry( user_id=wallet.wallet.user, user_account=user_account.name, @@ -1282,7 +1296,7 @@ async def api_create_income_entry( entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=data.amount, - reference=libra_reference, + reference=data.reference, entry_id=entry_id, ) @@ -1303,7 +1317,7 @@ async def api_create_income_entry( entry_date=data.entry_date if data.entry_date else datetime.now(), created_by=wallet.wallet.user, created_at=datetime.now(), - reference=libra_reference, + reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1389,7 +1403,7 @@ async def api_create_receivable_entry( # Format as Beancount entry and submit to Fava from .fava_client import get_fava_client - from .beancount_format import format_receivable_entry, sanitize_link + from .beancount_format import format_receivable_entry fava = get_fava_client() @@ -1401,12 +1415,8 @@ async def api_create_receivable_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Add libra ID as reference/link (sanitized for Beancount) - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - - # Format Beancount entry + # Format Beancount entry. Identity travels as entry-id metadata + + # rcv-{entry_id} link; the user reference becomes its own link. entry = format_receivable_entry( user_id=data.user_id, revenue_account=revenue_account.name, @@ -1416,8 +1426,8 @@ async def api_create_receivable_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=libra_reference, - entry_id=entry_id # Pass entry_id so all links match + reference=data.reference, + entry_id=entry_id ) # Submit to Fava @@ -1431,7 +1441,7 @@ async def api_create_receivable_entry( entry_date=datetime.now(), created_by=auth.user_id, created_at=datetime.now(), - reference=libra_reference, # Use libra reference with unique ID + reference=data.reference, flag=JournalEntryFlag.PENDING, meta=entry_meta, lines=[ @@ -1467,7 +1477,7 @@ async def api_create_revenue_entry( Submits entry to Fava/Beancount. """ from .fava_client import get_fava_client - from .beancount_format import format_revenue_entry, sanitize_link + from .beancount_format import format_revenue_entry # Get revenue account revenue_account = await get_account_by_name(data.revenue_account) @@ -1517,11 +1527,8 @@ async def api_create_revenue_entry( import uuid entry_id = str(uuid.uuid4()).replace("-", "")[:16] - # Add libra ID as reference/link (sanitized for Beancount) - libra_reference = f"libra-{entry_id}" - if data.reference: - libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" - + # Identity travels as entry-id metadata; the user reference becomes + # its own link. entry = format_revenue_entry( payment_account=payment_account.name, revenue_account=revenue_account.name, @@ -1530,7 +1537,8 @@ async def api_create_revenue_entry( entry_date=datetime.now().date(), fiat_currency=fiat_currency, fiat_amount=fiat_amount, - reference=libra_reference # Use libra reference with unique ID + reference=data.reference, + entry_id=entry_id, ) # Submit to Fava @@ -1545,7 +1553,7 @@ async def api_create_revenue_entry( entry_date=datetime.now(), created_by=auth.user_id, created_at=datetime.now(), - reference=libra_reference, + reference=data.reference, flag=JournalEntryFlag.CLEARED, lines=[], # Empty - entry is stored in Fava, not Libra DB meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} @@ -2857,21 +2865,14 @@ async def api_approve_expense_entry( # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() - # 2. Find the entry with matching libra ID in links + # 2. Find the pending transaction with matching canonical entry id target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": - links = entry.get("links", []) - for link in links: - # Strip ^ prefix if present (Beancount link syntax) - link_clean = link.lstrip('^') - # Check if this entry has our libra ID - if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"): - target_entry = entry - break - if target_entry: + if _extract_entry_id(entry) == entry_id: + target_entry = entry break if not target_entry: @@ -2973,21 +2974,14 @@ async def api_reject_expense_entry( # 1. Get all journal entries from Fava all_entries = await fava.get_journal_entries() - # 2. Find the entry with matching libra ID in links + # 2. Find the pending transaction with matching canonical entry id target_entry = None for entry in all_entries: # Only look at transactions with pending flag if entry.get("t") == "Transaction" and entry.get("flag") == "!": - links = entry.get("links", []) - for link in links: - # Strip ^ prefix if present (Beancount link syntax) - link_clean = link.lstrip('^') - # Check if this entry has our libra ID - if link_clean == f"libra-{entry_id}" or link_clean.endswith(f"-{entry_id}"): - target_entry = entry - break - if target_entry: + if _extract_entry_id(entry) == entry_id: + target_entry = entry break if not target_entry: