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

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