Skip to content

Records WOPC preview — Drive embed leak + transaction-linked payee/people broken

Renumbered T-126 → T-128 (2026-06-25, renumber-on-merge). While this task was in flight on claude/friendly-newton-bLnSw, a parallel agent (Accounting (Diagnostics)) grabbed T-126 on main first with the IR56M audit endpoint helper-sharing fix. Per the AGENTS.md "whoever merges second renumbers" rule, this task bounced to the next-free T-128; the in-code T-126 references in the commit messages and README log were updated for consistency. Source commit message stays historical T-126.

Why (owner, 2026-06-25)

"The WOPC tab on the Records page is showing a list of WOPCs, but clicking open the WOPC detail modal, the small preview window shows: EgxavxxoBveNmSdF78Cx

Also, when I click on a WOPC thru a transaction (20hjEVHBVk3bm53eSjqu) that's matched to 5050, in which the WOPC got created from, the WOPC is unable to show the payee's information, and regarding people's information, it's only the closing director that got rendered"

Two distinct bugs in one report.

Diagnosis

#1 — Records WOPC detail-drawer iframe shows a raw Drive file ID

WopcDetailDrawer in components/records/RecordsApp.tsx:495-503 built the iframe src differently per WOPC state:

if (showingVoided && row.voidedDriveFileId) {
  return `https://drive.google.com/file/d/${row.voidedDriveFileId}/preview`
}
if (showingSigned && latest?.signedDriveFileId) {
  return `https://drive.google.com/file/d/${latest.signedDriveFileId}/preview`
}
return `/api/records/wopcs/pdf?ref=${encodeURIComponent(row.referenceNumber)}`

For signed/voided WOPCs that branch embeds the Drive preview URL directly. But WOPC PDFs are uploaded by the accounting service account — the viewer's Google session never has access. Drive's response to the iframe is its access-required chrome that prominently displays the file ID (the "EgxavxxoBveNmSdF78Cx" the user pasted is the literal voidedDriveFileId of one of his voided WOPCs).

The local /api/records/wopcs/pdf?ref=… endpoint ALREADY does the latest-state pickup server-side (pages/api/records/wopcs/pdf.ts:119 voided → pages/api/records/wopcs/pdf.ts:144 signed-from-request → fall-through render). Using that endpoint for every state is the correct path: it stays on-domain, authenticates via the SA, and renders identically regardless of WOPC state.

#2 — Transaction-linked WOPC preview missing payee + contractor

The /accounting/payment-confirmation/[transactionId]/preview client calls /api/accounting/wopc/[referenceNumber] and pipes the doc through mapWOPCToPaymentConfirmation, which reads flat keys:

referenceNumber: wopc.referenceNumber,
payeeAbbreviation: wopc.payeeAbbreviation,
contractorName: wopc.contractorName || wopc.payeeName,
contractorAddress: wopc.contractorAddress || {},

The T-080 cleanup (2026-06-24, lib/wopcDocShape.ts docstring at L23) retired the legacy-shape fallback in the shared accessors — every prod WOPC now lives in the canonical bucketed shape (WOPC.refNumber, payee.name, contractor.name). The /api/accounting/wopc/<ref> endpoint returns the raw Firestore doc straight through. Post-T-080 the flat reads all hit undefined, and the preview rendered with empty payee + contractor + bank fields.

Only the closing director still rendered because its resolver falls through to the PaymentConfirmation component's hardcoded default when both payeeAbbreviation (now undefined) and closingDirectorId (never carried by the mapper) come up empty.

What shipped

components/records/RecordsApp.tsx

WopcDetailDrawer.previewUrl now always uses /api/records/wopcs/pdf?ref=… regardless of WOPC state. The showingVoided / showingSigned derivations stay — the title still shows the VOID / Signed badge.

pages/api/accounting/wopc/[referenceNumber].ts

The endpoint now applies toView(raw) from lib/wopcDocShape.ts and reshapes the canonical view back to the historic flat WOPCDocument contract via a new viewToFlatDocument helper. The wire shape the preview client + email templates receive is unchanged in structure — every flat key (referenceNumber, payeeAbbreviation, contractorName, bankName, closingDirectorId, …) is populated from the bucketed view's matching field. Soft-delete tombstone fields that aren't in the view (deletedAt/deletedBy, a tiny pre-#664 set) pass through from the raw doc.

app/accounting/payment-confirmation/[transactionId]/preview/client.tsx

mapWOPCToPaymentConfirmation now also forwards closingDirectorId when present. The PaymentConfirmation type doesn't declare the field (it's read via a duck-typed cast in the resolver call below), so the assignment goes through a spread + final as PaymentConfirmation. For non-director payees with no stored closingDirectorId, the resolver still falls through and the component's default director wins (unchanged behaviour for that branch).

Why this and not "fix mapWOPCToPaymentConfirmation to read bucketed fields"

A client-side dual-read would duplicate the legacy-shape adapter the shared wopcDocShape.ts deliberately retired in T-080. Single source of truth wins: the API endpoint applies toView exactly the way the records-side endpoint already does, so every WOPC consumer reads from one accessor instead of two.

Decision log

2026-06-25 — T-128 opened + shipped (renumbered from T-126)

  • Attestation (Records (Infrastructure)): read AGENTS.md; no open task covered either symptom. T-080 retired the dual-read in wopcDocShape.ts; the two consumers that still read flat keys (/api/accounting/wopc/<ref> + the Records drawer's Drive embed branch) weren't audited at the time.
  • Source: Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
  • Owner direction (verbatim, 2026-06-25): quoted above.
  • Tests + tsc: npx tsc --noEmit clean. Live verification left to the owner — open a signed WOPC's detail drawer on Records → expect the PDF iframe (not the Drive access-required chrome with the file ID). Open the WOPC preview via a transaction → expect the payee / contractor / bank fields to populate and the stored closing director to render (not the component default).
  • Blast radius: API endpoint reshape is internal (consumers already expected the flat shape; the field set is the same). The Records drawer change drops two URL branches in favour of the third, which already runs through the same Drive-SA fetch server-side. The mapper adds one optional field; the existing duck- typed cast in the closing-director resolver below still works.

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.

  • 6fd542b 2026-06-26 — docs(tasks): add T-128 WOPC preview fix record