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

@ -209,7 +209,8 @@ entry = format_transaction(
{"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"} {"account": "Liabilities:Payable:User-abc123", "amount": "-50000 SATS"}
], ],
tags=["groceries"], 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 # Submit to Fava
@ -217,6 +218,8 @@ client = get_fava_client()
result = await client.add_entry(entry) 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**: **Querying Balances**:
```python ```python
# Query user balance from Fava # Query user balance from Fava
@ -278,7 +281,8 @@ entry = format_transaction(
{"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"} {"account": "Assets:Lightning:Balance", "amount": "-50000 SATS"}
], ],
tags=["utilities"], tags=["utilities"],
links=["libra-tx-123"] links=["exp-0123456789abcdef"],
meta={"entry-id": "0123456789abcdef"}
) )
client = get_fava_client() client = get_fava_client()
@ -306,6 +310,13 @@ result = await client.query(query)
3. User accounts use `user_id` (NOT `wallet_id`) for consistency 3. User accounts use `user_id` (NOT `wallet_id`) for consistency
4. All accounting calculations delegated to Beancount/Fava 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`: **Validation** is performed in `core/validation.py`:
- Pure validation functions for entry correctness before submitting to Fava - Pure validation functions for entry correctness before submitting to Fava

View file

@ -945,7 +945,8 @@ def format_revenue_entry(
entry_date: date, entry_date: date,
fiat_currency: Optional[str] = None, fiat_currency: Optional[str] = None,
fiat_amount: Optional[Decimal] = None, fiat_amount: Optional[Decimal] = None,
reference: Optional[str] = None reference: Optional[str] = None,
entry_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Format a revenue entry (libra receives payment directly). Format a revenue entry (libra receives payment directly).
@ -962,7 +963,8 @@ def format_revenue_entry(
entry_date: Date of payment entry_date: Date of payment
fiat_currency: Optional fiat currency fiat_currency: Optional fiat currency
fiat_amount: Optional fiat amount (unsigned) 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: Returns:
Fava API entry dict Fava API entry dict
@ -978,6 +980,9 @@ def format_revenue_entry(
fiat_amount=Decimal("50.00") fiat_amount=Decimal("50.00")
) )
""" """
if not entry_id:
entry_id = generate_entry_id()
amount_sats_abs = abs(amount_sats) amount_sats_abs = abs(amount_sats)
fiat_amount_abs = abs(fiat_amount) if fiat_amount else None 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 # Note: created-via is redundant with #revenue-entry tag
entry_meta = { entry_meta = {
"source": "libra-api" "source": "libra-api",
"entry-id": entry_id
} }
links = [] links = []
if reference: if reference:
links.append(reference) links.append(sanitize_link(reference))
return format_transaction( return format_transaction(
date_val=entry_date, date_val=entry_date,

View file

@ -121,6 +121,7 @@ async def post_expense(
expense_account: str, expense_account: str,
currency: Optional[str] = "EUR", currency: Optional[str] = "EUR",
is_equity: bool = False, is_equity: bool = False,
reference: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""User submits an expense — creates Liability (libra owes user) or Equity contribution. """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, "user_wallet": user_wallet_id,
"currency": currency, "currency": currency,
"is_equity": is_equity, "is_equity": is_equity,
"reference": reference,
}, },
) )
assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}" assert r.status_code == 201, f"post_expense failed: {r.status_code} {r.text}"
@ -150,6 +152,7 @@ async def post_income(
description: str, description: str,
revenue_account: str, revenue_account: str,
currency: str = "EUR", currency: str = "EUR",
reference: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""User submits income on libra's behalf — creates Receivable (user owes libra).""" """User submits income on libra's behalf — creates Receivable (user owes libra)."""
r = await client.post( r = await client.post(
@ -160,13 +163,14 @@ async def post_income(
"amount": _amount(amount), "amount": _amount(amount),
"revenue_account": revenue_account, "revenue_account": revenue_account,
"currency": currency, "currency": currency,
"reference": reference,
}, },
) )
assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}" assert r.status_code == 201, f"post_income failed: {r.status_code} {r.text}"
return r.json() 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( r = await client.get(
"/libra/api/v1/entries/user", "/libra/api/v1/entries/user",
headers={"X-Api-Key": wallet_inkey}, 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() 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 # Entries — admin side
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -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"]

View file

@ -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 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 # account_utils.format_hierarchical_account_name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -434,6 +434,39 @@ async def api_get_journal_entries(
return enriched_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") @libra_api_router.get("/api/v1/entries/user")
async def api_get_user_entries( async def api_get_user_entries(
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
@ -524,18 +557,9 @@ async def api_get_user_entries(
continue continue
# Extract data for frontend # Extract data for frontend
# Extract entry ID from links # Resolve canonical entry ID (metadata first, link fallback)
entry_id = None entry_id = _extract_entry_id(e)
links = e.get("links", []) 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 # Extract amount from postings
amount_sats = 0 amount_sats = 0
@ -592,13 +616,15 @@ async def api_get_user_entries(
fiat_amount = float(cost_match.group(1)) fiat_amount = float(cost_match.group(1))
fiat_currency = cost_match.group(2) 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 reference = None
if isinstance(links, (list, set)): if isinstance(links, (list, set)):
for link in links: for link in links:
if isinstance(link, str): if isinstance(link, str):
link_clean = link.lstrip('^') 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 reference = link_clean
break break
@ -778,19 +804,9 @@ async def api_get_pending_entries(
for e in all_entries: for e in all_entries:
# Only include pending transactions that are NOT voided # Only include pending transactions that are NOT voided
if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []): if e.get("t") == "Transaction" and e.get("flag") == "!" and "voided" not in e.get("tags", []):
# Extract entry ID from links field # Resolve canonical entry ID (metadata first, link fallback)
entry_id = None entry_id = _extract_entry_id(e)
links = e.get("links", []) 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 # Extract user ID from metadata or account names
user_id = None user_id = None
@ -906,7 +922,11 @@ async def api_create_journal_entry(
Submits entry to Fava/Beancount. Submits entry to Fava/Beancount.
""" """
from .fava_client import get_fava_client 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 # Validate that entry balances to zero
total = sum(line.amount for line in data.lines) total = sum(line.amount for line in data.lines)
@ -975,7 +995,7 @@ async def api_create_journal_entry(
tags = data.meta.get("tags", []) tags = data.meta.get("tags", [])
links = data.meta.get("links", []) links = data.meta.get("links", [])
if data.reference: 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 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"]} 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 # Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client 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() fava = get_fava_client()
@ -1140,12 +1160,8 @@ async def api_create_expense_entry(
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Add libra ID as reference/link (sanitized for Beancount) # Format Beancount entry. Identity travels as entry-id metadata +
libra_reference = f"libra-{entry_id}" # exp-{entry_id} link; the user reference becomes its own link.
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Format Beancount entry
entry = format_expense_entry( entry = format_expense_entry(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
expense_account=expense_account.name, expense_account=expense_account.name,
@ -1156,8 +1172,8 @@ async def api_create_expense_entry(
is_equity=data.is_equity, is_equity=data.is_equity,
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=libra_reference, reference=data.reference,
entry_id=entry_id # Pass entry_id so all links match entry_id=entry_id
) )
# Submit to Fava # 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(), entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(), created_at=datetime.now(),
reference=libra_reference, reference=data.reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
@ -1262,17 +1278,15 @@ async def api_create_income_entry(
# Submit to Fava # Submit to Fava
from .fava_client import get_fava_client 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() fava = get_fava_client()
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
libra_reference = f"libra-{entry_id}" # Identity travels as entry-id metadata + inc-{entry_id} link; the
if data.reference: # user reference becomes its own link.
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
entry = format_income_entry( entry = format_income_entry(
user_id=wallet.wallet.user, user_id=wallet.wallet.user,
user_account=user_account.name, 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(), entry_date=data.entry_date.date() if data.entry_date else datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=data.amount, fiat_amount=data.amount,
reference=libra_reference, reference=data.reference,
entry_id=entry_id, 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(), entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.user, created_by=wallet.wallet.user,
created_at=datetime.now(), created_at=datetime.now(),
reference=libra_reference, reference=data.reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
@ -1389,7 +1403,7 @@ async def api_create_receivable_entry(
# Format as Beancount entry and submit to Fava # Format as Beancount entry and submit to Fava
from .fava_client import get_fava_client 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() fava = get_fava_client()
@ -1401,12 +1415,8 @@ async def api_create_receivable_entry(
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Add libra ID as reference/link (sanitized for Beancount) # Format Beancount entry. Identity travels as entry-id metadata +
libra_reference = f"libra-{entry_id}" # rcv-{entry_id} link; the user reference becomes its own link.
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
# Format Beancount entry
entry = format_receivable_entry( entry = format_receivable_entry(
user_id=data.user_id, user_id=data.user_id,
revenue_account=revenue_account.name, revenue_account=revenue_account.name,
@ -1416,8 +1426,8 @@ async def api_create_receivable_entry(
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=libra_reference, reference=data.reference,
entry_id=entry_id # Pass entry_id so all links match entry_id=entry_id
) )
# Submit to Fava # Submit to Fava
@ -1431,7 +1441,7 @@ async def api_create_receivable_entry(
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=auth.user_id, created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=libra_reference, # Use libra reference with unique ID reference=data.reference,
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
meta=entry_meta, meta=entry_meta,
lines=[ lines=[
@ -1467,7 +1477,7 @@ async def api_create_revenue_entry(
Submits entry to Fava/Beancount. Submits entry to Fava/Beancount.
""" """
from .fava_client import get_fava_client 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 # Get revenue account
revenue_account = await get_account_by_name(data.revenue_account) revenue_account = await get_account_by_name(data.revenue_account)
@ -1517,11 +1527,8 @@ async def api_create_revenue_entry(
import uuid import uuid
entry_id = str(uuid.uuid4()).replace("-", "")[:16] entry_id = str(uuid.uuid4()).replace("-", "")[:16]
# Add libra ID as reference/link (sanitized for Beancount) # Identity travels as entry-id metadata; the user reference becomes
libra_reference = f"libra-{entry_id}" # its own link.
if data.reference:
libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"
entry = format_revenue_entry( entry = format_revenue_entry(
payment_account=payment_account.name, payment_account=payment_account.name,
revenue_account=revenue_account.name, revenue_account=revenue_account.name,
@ -1530,7 +1537,8 @@ async def api_create_revenue_entry(
entry_date=datetime.now().date(), entry_date=datetime.now().date(),
fiat_currency=fiat_currency, fiat_currency=fiat_currency,
fiat_amount=fiat_amount, fiat_amount=fiat_amount,
reference=libra_reference # Use libra reference with unique ID reference=data.reference,
entry_id=entry_id,
) )
# Submit to Fava # Submit to Fava
@ -1545,7 +1553,7 @@ async def api_create_revenue_entry(
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=auth.user_id, created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=libra_reference, reference=data.reference,
flag=JournalEntryFlag.CLEARED, flag=JournalEntryFlag.CLEARED,
lines=[], # Empty - entry is stored in Fava, not Libra DB lines=[], # Empty - entry is stored in Fava, not Libra DB
meta={"source": "fava", "fava_response": result.get('data', 'Unknown')} 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 # 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries() 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 target_entry = None
for entry in all_entries: for entry in all_entries:
# Only look at transactions with pending flag # Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!": if entry.get("t") == "Transaction" and entry.get("flag") == "!":
links = entry.get("links", []) if _extract_entry_id(entry) == entry_id:
for link in links: target_entry = entry
# 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 break
if not target_entry: if not target_entry:
@ -2973,21 +2974,14 @@ async def api_reject_expense_entry(
# 1. Get all journal entries from Fava # 1. Get all journal entries from Fava
all_entries = await fava.get_journal_entries() 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 target_entry = None
for entry in all_entries: for entry in all_entries:
# Only look at transactions with pending flag # Only look at transactions with pending flag
if entry.get("t") == "Transaction" and entry.get("flag") == "!": if entry.get("t") == "Transaction" and entry.get("flag") == "!":
links = entry.get("links", []) if _extract_entry_id(entry) == entry_id:
for link in links: target_entry = entry
# 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 break
if not target_entry: if not target_entry: