Approving a pending income entry 404s: entry id resolves to "unknown" in list endpoints #42

Closed
opened 2026-06-12 12:04:30 +00:00 by padreug · 2 comments
Owner

Symptom

Approving a pending income entry from the admin UI fails with a toast:

Pending entry unknown not found in Beancount ledger (404)

Reproduces for income entries created via POST /api/v1/entries/income. Example ledger entry that fails to approve:

2026-06-12 ! "2 Memberships (700.00 USD)" #income-entry ^42-144-934c956acd364f3b ^inc-934c956acd364f3b
  user-id: "969bcda6823b4b65b23c60f16bbf39e0"
  source: "libra-api"
  entry-id: "934c956acd364f3b"
  Assets:Receivable:User-969bcda6      700.00 USD @ 1589.27... SATS
  Income:MemberDuesContributions      -700.00 USD @ 1589.27... SATS

Root cause

The two journal-list endpoints — GET /api/v1/entries/user and GET /api/v1/entries/pending — extract the entry id by searching the links for the substring libra-:

if "libra-" in link_clean:
    parts = link_clean.split("libra-")
    entry_id = parts[-1]

But the entry formatters now emit typed link prefixes (inc-{id}, exp-{id}, rcv-{id}) and only fall back to a libra-{id} link when no reference is set (beancount_format.format_income_entry etc.). The income entry above has links inc-934c956acd364f3b and 42-144-934c956acd364f3b — neither contains libra-, so entry_id stays None and is rendered as the literal string "unknown" (entry_id or "unknown").

The UI then POSTs to /api/v1/entries/unknown/approve, and the approve handler can't find a transaction whose link ends in -unknown, returning 404.

The approve/reject handlers themselves are fine — they already match link_clean.endswith(f"-{entry_id}"), so they resolve correctly once given the real id. The bug is purely in the list endpoints producing a bad id for the frontend.

Note: this also affects expense and receivable entries that carry a custom reference (their primary link is exp-/rcv- prefixed, not libra-); income just surfaced it because it always uses inc-.

Fix

Resolve the id from the canonical entry-id transaction metadata (written by every libra entry formatter) before falling back to link parsing. A shared _extract_entry_id() helper now does meta-first resolution with a legacy link fallback (libra-{id} and typed inc-/exp-/rcv- prefixes), used by both list endpoints.

Fixed on main (views_api.py). Surfaced on a recently-upgraded production instance where members couldn't approve income entries.

Follow-up (not in this fix)

  • Add a regression test asserting GET /api/v1/entries/pending returns the real id (not "unknown") for income/expense/receivable entries with a reference set.
  • Consider dropping the "unknown" sentinel entirely in favor of omitting non-approvable entries' ids, so a future regression fails loudly instead of round-tripping a bad id.
## Symptom Approving a pending **income** entry from the admin UI fails with a toast: > Pending entry **unknown** not found in Beancount ledger (404) Reproduces for income entries created via `POST /api/v1/entries/income`. Example ledger entry that fails to approve: ```beancount 2026-06-12 ! "2 Memberships (700.00 USD)" #income-entry ^42-144-934c956acd364f3b ^inc-934c956acd364f3b user-id: "969bcda6823b4b65b23c60f16bbf39e0" source: "libra-api" entry-id: "934c956acd364f3b" Assets:Receivable:User-969bcda6 700.00 USD @ 1589.27... SATS Income:MemberDuesContributions -700.00 USD @ 1589.27... SATS ``` ## Root cause The two journal-list endpoints — `GET /api/v1/entries/user` and `GET /api/v1/entries/pending` — extract the entry id by searching the links for the substring `libra-`: ```python if "libra-" in link_clean: parts = link_clean.split("libra-") entry_id = parts[-1] ``` But the entry formatters now emit **typed link prefixes** (`inc-{id}`, `exp-{id}`, `rcv-{id}`) and only fall back to a `libra-{id}` link when no `reference` is set (`beancount_format.format_income_entry` etc.). The income entry above has links `inc-934c956acd364f3b` and `42-144-934c956acd364f3b` — neither contains `libra-`, so `entry_id` stays `None` and is rendered as the literal string `"unknown"` (`entry_id or "unknown"`). The UI then POSTs to `/api/v1/entries/unknown/approve`, and the approve handler can't find a transaction whose link ends in `-unknown`, returning 404. The approve/reject handlers themselves are fine — they already match `link_clean.endswith(f"-{entry_id}")`, so they resolve correctly once given the real id. The bug is purely in the **list** endpoints producing a bad id for the frontend. Note: this also affects **expense** and **receivable** entries that carry a custom `reference` (their primary link is `exp-`/`rcv-` prefixed, not `libra-`); income just surfaced it because it always uses `inc-`. ## Fix Resolve the id from the canonical `entry-id` **transaction metadata** (written by every libra entry formatter) before falling back to link parsing. A shared `_extract_entry_id()` helper now does meta-first resolution with a legacy link fallback (`libra-{id}` and typed `inc-`/`exp-`/`rcv-` prefixes), used by both list endpoints. Fixed on `main` (`views_api.py`). Surfaced on a recently-upgraded production instance where members couldn't approve income entries. ## Follow-up (not in this fix) - Add a regression test asserting `GET /api/v1/entries/pending` returns the real id (not `"unknown"`) for income/expense/receivable entries with a `reference` set. - Consider dropping the `"unknown"` sentinel entirely in favor of omitting non-approvable entries' ids, so a future regression fails loudly instead of round-tripping a bad id.
Author
Owner

Confirmed: exact trigger is a custom reference, not the entry type

The differentiator is whether the entry was created with a reference — not income vs expense, and not a software version difference between servers. Two real entries, same code:

Fails (still !, id rendered unknown):

2026-06-12 ! "2 Memberships (700.00 USD)" #income-entry ^42-144-934c956acd364f3b ^inc-934c956acd364f3b
  entry-id: "934c956acd364f3b"

reference 42-144 set → links are inc-… + 42-144-…no libra- link → list extraction returns "unknown"POST /entries/unknown/approve → 404.

Works (approved, now *):

2026-06-10 * "2-night stay (155.00 EUR)" #income-entry ^inc-5849884c91b54dae ^libra-5849884c91b54dae
  entry-id: "5849884c91b54dae"

no reference → libra_reference = f"libra-{entry_id}"libra-… link present → list extraction succeeds → approves fine.

Source of the missing libra- link (views_api.py, income/expense/receivable creation):

libra_reference = f"libra-{entry_id}"
if data.reference:
    libra_reference = f"{sanitize_link(data.reference)}-{entry_id}"  # default libra- link is gone

So the "worked on another server" observation was not a version difference — that instance simply approved an entry that had no reference. Any reference-bearing pending entry (income, expense, or receivable) is affected.

The metadata-first _extract_entry_id() fix resolves all cases since entry-id is written on every entry regardless of reference.

## Confirmed: exact trigger is a custom `reference`, not the entry type The differentiator is **whether the entry was created with a `reference`** — not income vs expense, and not a software version difference between servers. Two real entries, same code: **Fails (still `!`, id rendered `unknown`):** ```beancount 2026-06-12 ! "2 Memberships (700.00 USD)" #income-entry ^42-144-934c956acd364f3b ^inc-934c956acd364f3b entry-id: "934c956acd364f3b" ``` reference `42-144` set → links are `inc-…` + `42-144-…` → **no `libra-` link** → list extraction returns `"unknown"` → `POST /entries/unknown/approve` → 404. **Works (approved, now `*`):** ```beancount 2026-06-10 * "2-night stay (155.00 EUR)" #income-entry ^inc-5849884c91b54dae ^libra-5849884c91b54dae entry-id: "5849884c91b54dae" ``` no reference → `libra_reference = f"libra-{entry_id}"` → `libra-…` link present → list extraction succeeds → approves fine. Source of the missing `libra-` link (`views_api.py`, income/expense/receivable creation): ```python libra_reference = f"libra-{entry_id}" if data.reference: libra_reference = f"{sanitize_link(data.reference)}-{entry_id}" # default libra- link is gone ``` So the "worked on another server" observation was **not** a version difference — that instance simply approved an entry that had no `reference`. Any reference-bearing pending entry (income, expense, or receivable) is affected. The metadata-first `_extract_entry_id()` fix resolves all cases since `entry-id` is written on every entry regardless of reference.
Author
Owner

Fixed in 15d9910 (on main).

Final design — entry-id transaction metadata is the single canonical identity:

  • _extract_entry_id() resolves metadata-first; used by /entries/user, /entries/pending, approve, and reject (libra- link parsing kept only for pre-dfdcc44 ledger history).
  • Creation endpoints no longer fuse the user reference with the entry id — the reference becomes its own sanitized link and round-trips verbatim in responses. Typed exp-/rcv-/inc- links remain the settlement-tracking handles (BQL matching unchanged).
  • Drive-bys: format_revenue_entry now writes entry-id metadata + sanitizes its reference link; generic POST /entries sanitizes its reference link; user-journal reference extraction no longer leaks typed system links.

Contract documented in CLAUDE.md (Data Integrity → Entry Identity & Links). Pinned by tests/test_entry_identity_api.py (4 integration regression tests, including the exact production shape: income + 42-144 reference → real id in pending list → approve succeeds) and formatter contract tests in test_unit.py. Full suite: 117 passed; remaining failures pre-existing/environmental (filed separately).

For the stuck production entry: it has entry-id metadata, so after deploying this it will list with its real id and approve normally — no ledger surgery needed.

Fixed in 15d9910 (on main). Final design — `entry-id` transaction metadata is the single canonical identity: - `_extract_entry_id()` resolves metadata-first; used by `/entries/user`, `/entries/pending`, approve, and reject (`libra-` link parsing kept only for pre-dfdcc44 ledger history). - Creation endpoints no longer fuse the user reference with the entry id — the reference becomes its own sanitized link and round-trips verbatim in responses. Typed `exp-/rcv-/inc-` links remain the settlement-tracking handles (BQL matching unchanged). - Drive-bys: `format_revenue_entry` now writes `entry-id` metadata + sanitizes its reference link; generic `POST /entries` sanitizes its reference link; user-journal `reference` extraction no longer leaks typed system links. Contract documented in CLAUDE.md (Data Integrity → Entry Identity & Links). Pinned by `tests/test_entry_identity_api.py` (4 integration regression tests, including the exact production shape: income + `42-144` reference → real id in pending list → approve succeeds) and formatter contract tests in `test_unit.py`. Full suite: 117 passed; remaining failures pre-existing/environmental (filed separately). For the stuck production entry: it has `entry-id` metadata, so after deploying this it will list with its real id and approve normally — no ledger surgery needed.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/libra#42
No description provided.