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:
parent
116df46d38
commit
15d9910073
6 changed files with 374 additions and 89 deletions
158
views_api.py
158
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue