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.signatureabsent), and carried noWOPC.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/deletefetches ininterceptUnmatchForWopcnow POST{ transactionId: id }(withContent-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 β canonicalWOPC.transaction.idβ legacy flattransactionId. Before clearing, it re-reads the tx and verifiestx.wopcReferenceNumber === referenceNumber, so a stale/wrong id can never wipe an unrelated match. ThegetTransactionimport is added alongside the existinguncategorizeTransaction.
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; onlypomJYwFk4v2XEXg8rpZyhad a dead WOPC + terminal/unsigned request). Blast radius = 1. - The full tx doc was backed up first, then a one-off remediation faithfully
mirroring
uncategorizeTransactionclearedgl.5050and set the status tounmatched(audit entryby: 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 --noEmitclean.npx vitest runβ full suite green (wopcDocShape25/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
/deletenow 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. getPdftest 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.
d0229842026-06-29 β fix(ir56m): count only remuneration GL 5050, not the whole expense set (T-137)fbf04602026-06-30 β docs(T-136): bump next-free UID T-136 β T-137