Skip to content

Withdrawn/tombstoned WOPC leaves a dangling wopcRef that blocks tx unmatch/unassign forever

Why (owner, 2026-06-29)

"While, I have unmatched/ unassigned this tx (pomJYwFk4v2XEXg8rpZy) that was previously matched to GL 5050. The WOPC that was created along seems to have been withdrawn, but the tx won't get unmatched/unassigned somehow"

A bank transaction stayed permanently categorized: every attempt to unmatch / unassign it failed even though its WOPC had already been withdrawn and tombstoned.

Diagnosis

Ground truth for tx pomJYwFk4v2XEXg8rpZy (read-only diagnostic across the three databases):

  • transaction.status = "categorized", gl.5050.wopcRef = "ERL-WOPC/2025-025".
  • The WOPC doc was tombstoned (WOPC.deleted = { reason: "manually-cancelled", … }), unsigned (WOPC.signature absent), and carried no WOPC.transaction.id.
  • Its only signing request was withdrawn (terminal).

The unmatch/unassign cascade is supposed to clear the tx back-reference through POST /api/accounting/wopc/[referenceNumber]/delete, whose step 4 clears the tx with uncategorizeTransaction(...). But that step was gated on if (wopc.transactionId).

getWOPCByReferenceNumber returns the raw canonical WOPC doc cast to the flat WOPCDocument type. For bucketed docs the back-reference lives at WOPC.transaction.id, so the flat wopc.transactionId reads undefined β€” and for this WOPC even WOPC.transaction.id was missing. The guard therefore skipped the tx cleanup on every call, leaving gl.5050.wopcRef in place. The unassign-account action then refuses with HTTP 409 ("void or delete the WOPC first") whenever a wopcReferenceNumber is still derivable β€” so the dangling ref blocked unmatch and unassign indefinitely. The WOPC was already gone, so there was nothing left for the user to void/delete to clear it.

What shipped

Code (prevents recurrence):

  • components/accounting/transactionWorkspace/useTransactionWorkspace.tsx β€” both /delete fetches in interceptUnmatchForWopc now POST { transactionId: id } (with Content-Type: application/json). The caller always knows the tx id even when the WOPC doc has lost its back-reference.
  • pages/api/accounting/wopc/[referenceNumber]/delete.ts β€” step 4 now resolves the tx id in priority order: caller body β†’ canonical WOPC.transaction.id β†’ legacy flat transactionId. Before clearing, it re-reads the tx and verifies tx.wopcReferenceNumber === referenceNumber, so a stale/wrong id can never wipe an unrelated match. The getTransaction import is added alongside the existing uncategorizeTransaction.

Data (cleared the one already-stuck record):

  • A read-only scan of all 180 transactions found exactly one stuck record (35 carry a gl.<code>.wopcRef; only pomJYwFk4v2XEXg8rpZy had a dead WOPC + terminal/unsigned request). Blast radius = 1.
  • The full tx doc was backed up first, then a one-off remediation faithfully mirroring uncategorizeTransaction cleared gl.5050 and set the status to unmatched (audit entry by: system:stuck-wopc-cleanup). The owner then re-assigned the tx to GL 1120 through the UI β€” confirming the record was unblocked. Post-fix re-scan: 0 stuck records remain.

Companion fix β€” main green (WOPC review)

A Codex commit loosened getPdf in lib/wopcDocShape.ts to return a partial { fileId, signedFileId, storagePath } whenever any field is present (so a signed-only WOPC β€” signedFileId with no original fileId/storagePath β€” is no longer dropped; every consumer already resolves it as pdf?.signedFileId ?? pdf?.fileId). The two __tests__/lib/wopcDocShape.test.ts cases still asserted the old "require both β†’ undefined" contract and were failing on main. They now assert the intended any-field-present contract (and a new signed-only case), restoring green.

Verification

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; checked the board by scope, not UID β€” no existing task covered the dangling-wopcRef unmatch block. Tracking T-137.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Diagnosed and blast-radius-scanned with read-only scripts against production (tebs-erl / tebs-epl / aote-system).
  • NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit clean.
  • npx vitest run β€” full suite green (wopcDocShape 25/25; the two prior failures fixed).
  • Owner preference recorded (2026-06-29): ask before any production data write going forward β€” diagnose + prepare, then get explicit OK.

Blast Radius

  • WOPC /delete now always receives and verifies the tx id, so the unmatch/delete cascade reliably detaches the tx even when the WOPC doc has lost its back-reference. The verification (tx.wopcReferenceNumber === referenceNumber) means an incorrect caller-supplied id is ignored rather than acted on.
  • getPdf test contract aligned to the shipped behavior; no runtime change.
  • One production transaction (pomJYwFk4v2XEXg8rpZy) cleared and re-categorized by the owner. No other data touched.

Commit index (backfilled 2026-07-01, best-effort Β· Coaching (Diagnostic))

Candidate related commits, auto-backfilled from git on main: commits whose message references this task's UID or a PR number it cites. Not verified β€” this squash-merged history can't yield a precise per-task list, so rows tagged (mentions only) name the task in passing (may be tangential) and untagged work commits may be missing. Treat as a starting point: verify, prune tangential rows, and append any real ones per the AGENTS.md "record every related SHA" policy.

  • d022984 2026-06-29 β€” fix(ir56m): count only remuneration GL 5050, not the whole expense set (T-137)
  • fbf0460 2026-06-30 β€” docs(T-136): bump next-free UID T-136 β†’ T-137