Resolve entry identity via entry-id metadata; unfuse user references (libra-#42)

Approving a pending entry created with a reference (e.g. invoice
"42-144") 404'd with "Pending entry unknown not found": the list
endpoints recovered the entry id by parsing links for a libra- prefix,
but reference-bearing entries displace that link with the fused
"{reference}-{entry_id}" form, so the id surfaced as the literal
"unknown" and the approve call round-tripped it.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-12 20:39:06 +02:00
commit 15d9910073
6 changed files with 374 additions and 89 deletions

View file

@ -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: