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
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
168
tests/test_entry_identity_api.py
Normal file
168
tests/test_entry_identity_api.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue