Skip to content

uid: T-191 title: A transaction stores references, not copies β€” resolve WOPC-payout display detail from the WOPC at render time, retire the stale gl['5050'] copies status: done area: accounting created: 2026-07-27 updated: 2026-07-27 assignee: Accounting (Diagnostics) owner: girafeev1 related: T-089, T-168, T-178, T-193


T-191 β€” A transaction stores references, not copies

UID note: routed to the board as "T-190" in the original brief, but T-190 was already taken ("Approvals route to a rank" β€” User Management, 2026-07-27). Renumbered to T-191 at the owner's instruction (2026-07-27). Forward references only; the brief's prose is preserved below.

Why

When a bank transaction is matched to a sub-contractor-fee payment (GL 5050), the payment's real detail lives on the WOPC document attached to it β€” each WOPC line item traces back to the originating project (invoiceItemRef) or coaching session (coachingInvoiceRef). But the transaction ALSO stores a flattened copy of some of that β€” relatedProjectId, relatedProjectTitle, relatedProjectYear, presenterWorkType, clientCompany β€” inside gl['5050'], purely so the ledger row's display name resolves without a second read.

Two defects with the copy: - Stale: editing a WOPC writes an empty transaction update, so the copied project fields are never re-synced and the two silently disagree. - Lossy: a multi-project WOPC stores only the first line item's project on the transaction; the full breakdown exists only on the WOPC.

Owner: routed to Accounting (Diagnostics) 2026-07-27 β€” "(Diagnostic) means tackling issues on existing infrastructure instead of building or extending it."

Do

Make display-name / token resolution read that detail from the attached WOPC at render time instead of from the transaction copy. Audit coaching and project/coaching invoice matches for the same denormalization.

Don't touch (these are the transaction's own facts, not copies of the WOPC)

  • relatedProjectId on a direct project-expense categorization with no WOPC β€” the operator chose it; it stays.
  • Allocation / match data (gl['4000'] invoice allocations, gl.coaching, gl.coachingInvoices) β€” which invoice / how much.
  • The WOPC's own projectSnapshot / itemSnapshot β€” a WOPC is a document of record and must show what it said word-for-word.

The catch to respect

The copy exists as a read-performance shortcut. Reading from the WOPC during enrichment means loading it per row β€” batch or cache it, or a long ledger becomes N+1 reads. Use expand β†’ migrate β†’ contract: read-prefer the WOPC with fallback to the stored copy, remove the stored fields only after a backfill / verify.

Rules

The Firestore-structure gate in AGENTS.md applies β€” present the whole transaction document current-vs-after and get owner approval before removing / relocating any field. tsc clean, vitest green (compare totals), eslint at baseline, one PR, deploy only on command.

Current-state findings (Accounting (Diagnostics), 2026-07-27 β€” verified in code)

The render-time WOPC read already exists but is wired as the last resort, not the primary:

  • enrichTransactionDisplayName (lib/accounting/transactions.ts) already loads the WOPC for any tx with a wopcReferenceNumber (getWOPCByReferenceNumber β†’ toView β†’ getDistinctProjectInfos over lineItems[*].projectSnapshot).
  • But the token resolution order (β‰ˆ line 929–932) is copy-first: projectData?.projectTitle || expenseMetadata.relatedProjectTitle || wopcProjectTitle, where projectData is fetched from the stored relatedProjectId. So the stored copies win; the WOPC snapshot only fills in when projectData is null (the manual-WOPC case, whose synthetic manual-… projectId resolves to no project doc).
  • Reference-vs-value distinction worth carrying into the design: relatedProjectId is a pointer (the key used to re-read the live project doc), whereas relatedProjectTitle / relatedProjectYear / presenterWorkType / clientCompany are value snapshots β€” those are the ones that actually drift. relatedProjectId for the WOPC case is written by the matcher (invoiceAutoMatch.server.ts β‰ˆ :804), distinct from the operator-chosen no-WOPC case above.
  • The N+1 is therefore already latent (a per-row WOPC read happens today for WOPC-matched rows); getProjectCached is the existing caching pattern to mirror.

Approach = still in design (owner discussion in progress). This log will carry the agreed expand→migrate→contract plan + the current-vs-after document diff before any field is removed.

2026-07-27 β€” owner design decisions (verbatim)

  1. No permanent fallback to the stored copy β€” fail loud instead. Owner: "if it's not cost bearing as well, then a) I don't want any of these fallback cos the web app supposed to work. If enriched details suddenly stop showing, then we'll know that the web app is broken, and b) when things don't work, a fallback only hides it." β†’ End state reads project display fields from the attached WOPC ONLY; a missing/unreadable WOPC renders empty (a visible signal), never a stale copy. (The expand-phase dual-read is transitional migration scaffolding only, removed at contract.)
  2. The WOPC is authoritative; per-line-item traceback is a luxury, not a requirement. Owner: "the WOPC itself should be authoritative enough for the web app to take whatever's on it with utmost weight. No one says that all items listed on a WOPC has to be traceable to an actual project or coaching session … as my invoices to clients don't hold all the truth anyways." β†’ Enrichment reads the WOPC's OWN lineItems[*].projectSnapshot; it must NOT re-resolve to a project doc, and must NOT assume every line item maps to a project/coaching session. invoiceItemRef / coachingInvoiceRef become optional provenance (future: clickable origin links), not a render dependency.

Consequences for the design (both decisions pull the SAME way β€” simpler + cheaper): - For GL-5050/WOPC rows, stop reading the project doc (getProjectCached(relatedProjectId)) and the project's invoice entirely; the WOPC's own snapshot is the single source. - Kill the real cost driver: getWOPCByReferenceNumber currently scans ALL payees (one payees list + a point-read per payee until found) on EVERY call β€” that per-row Γ— per-payee scan is the actual N+1, not the "read a WOPC per row" itself. The tx already stores gl['5050'].payeeAbbreviation (written at match time), so a WOPC is directly addressable at payees/{abbr}/wopc/{docId} β€” one read, no scan. - N+1 fix (proposed, pending owner nod): in the batch entry enrichTransactionDisplayNames (which already pre-fetches a workspace-invoice map the same way), add a pre-pass that collects the distinct (payeeAbbreviation, wopcReferenceNumber) pairs and does ONE batched getAll(...refs), building a Map<wopcRef, WOPCView> passed into each row. Net reads go DOWN vs today (one bounded multi-get replaces N all-payees scans + N project reads). - Open item flagged for the owner: clientCompany is the one rendered field NOT on the WOPC's projectSnapshot (today it comes from the project's invoice). Under decisions 1+2 it either drops for 5050 rows or must be captured onto the WOPC at creation β€” owner decision pending. - Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” open items resolved (owner + live-data probe)

  1. clientCompany DROPPED for GL-5050/WOPC rows. Owner: "a WOPC is not meant to make payments for a client company, so client company should be dropped regardless." β†’ The clientCompany token is not rendered for sub-contractor-fee rows; the stored gl['5050'].clientCompany copy is removed with the others.
  2. Direct-addressing needs no backfill or scan β€” verified against production. The "rare legacy row with no payee abbreviation" I raised was hypothetical; the owner asked which row exactly. Read-only probe over ALL 184 tebs-erl transactions: 34 are WOPC-linked (all GL 5050); 34/34 carry a stored gl['5050'].payeeAbbreviation (33 JC, 1 JN). So a WOPC is directly addressable at payees/{storedAbbr}/wopc/{toStorageId(wopcRef)} for 100% of real rows β€” no all-payees scan, no backfill. (Aside: every current reference is the legacy abbr-less form ERL-WOPC/YYYY-NNN, so ref-string parsing would cover 0% β€” but the stored field covers 100%, which is what the batched getAll uses.)

Design (superseded by the 2026-07-27 goal-expansion below): batch pre-fetch via getAll on payees/{storedAbbr}/wopc/{id} β†’ enrich from the WOPC's projectSnapshot β†’ drop clientCompany β†’ no fallback β†’ delete the value-copies. - Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” owner expands the goal: the tx stores ONLY wopcRef (retire payeeAbbreviation too)

Owner (verbatim): "I also wanna retire the payee abbr from a tx matched to 5050 and 2110 (the 2 only GL account that requires the attachment of WOPC ref no. to a tx firestore doc for the time being) as I wanna make the storing of WOPC ref no. on a tx to be sufficient enough. (Tx matched to a GL that needs the attachment of a WOPC ref no. to look just at the WOPC for all information needed)"

End-state: a 5050/2110 transaction's gl[code] carries only wopcRef as the WOPC hook β€” payeeAbbreviation is retired alongside the project value-copies. The WOPC is the sole source.

The coupling this creates: payeeAbbreviation on the tx is today the ONLY key that locates the WOPC in one read (WOPCs live at payees/{abbr}/wopc/{id}; the reference does NOT encode the payee β€” the generator mints the legacy abbr-less ERL-WOPC/YYYY-NNN and ignores its abbreviation arg). So retiring it means a bare wopcRef must still resolve to its WOPC without the all-payees scan. "Make wopcRef sufficient" and "find the WOPC from the ref alone" are now the same problem.

Resolved WITHOUT migration or a back-pointer (verified 2026-07-27): a collectionGroup('wopc').where('WOPC.refNumber','==', ref) query finds the WOPC regardless of payee. Probe over collectionGroup('wopc') (tebs-epl): 36/36 docs carry WOPC.refNumber, and 36/36 have docId === toStorageId(refNumber) β€” so lookups are one indexed query (batched for a ledger page via where('WOPC.refNumber','in', [...]), ≀30/chunk). No stored abbr, no payeeβ†’WOPC hook (still one direction). Cost: one collectionGroup index on WOPC.refNumber.

Two paths for the owner to choose (A recommended): - (A) collectionGroup lookup β€” IN LANE (diagnostics), recommended. Keep nested storage; resolve wopcRef via the collectionGroup query + one index. Achieves the exact goal (tx = only wopcRef), low risk, no data move, no rules change. Retire payeeAbbreviation + the value-copies once reads go through the WOPC. - (B) centralize WOPCs to top-level wopcs/{id} β€” INFRASTRUCTURE. Ref becomes a direct point-read; also kills the create-time counter's all-payees scan (scanActualMaxSequence) and simplifies getWOPCByReferenceNumber's ~15 call sites. Cleaner end-state but a real migration + write-path/rules refactor β€” out of the diagnostics lane; would be a separate Infra task.

Coordination / notes: - 2110 is T-178 (reimbursement WOPCs, Records (Infrastructure), in-flight) β€” no 2110-WOPC txs exist yet (all 34 current WOPC rows are GL 5050). The retire-abbr design must be forward-compatible with 2110 and coordinated with T-178's owner. - The WOPC WRITE path (updateWOPCMatched(abbreviation, …)) already holds view.payee.abbreviation at link time, so retiring the tx copy doesn't break writes; other expenseMetadata.payeeAbbreviation readers to be audited. - Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” Path A chosen; audit done; STRUCTURE-GATE current-vs-after (awaiting owner approval)

Owner: "If the index works just as if WOPCs are centralized, A it is." β†’ Path A (collectionGroup lookup + one index; no migration). 2110 handover β†’ T-192 (Records (Infrastructure)).

Audit β€” readers of the tx's own payeeAbbreviation copy (only TWO, both WOPC-satisfiable): - lib/accounting/transactions.ts:1091 β€” enrichment builds entityRefs.payeeId + the clickable wopcPath = payees/{abbr}/wopc/{ref}; source view.payee.abbreviation instead. - components/accounting/transactionWorkspace/AttachmentsPanel.tsx:406 β€” a <Tag>; source from the WOPC the panel already loads. Everything else in the grep reads the WOPC's own abbreviation or is a WRITE site to retire (wopcAutoMatch, TransactionLinkingModal, projectWopcAuto, coachingWopcAuto, transactionAutoLinker, invoiceAutoMatch). - No gl['5050'] sub-field is QUERIED (no where/report join on relatedProjectId or any copy) β€” retirement is display-only surgery, no reporting-grouping breakage.

STRUCTURE GATE β€” real gl['5050'] (tx Cc7VWE0x7sR2YP0vyFMI, WOPC ERL-WOPC/2026-007), current β†’ after. The copy is the WHOLE WOPC payload (17 keys), not the 5 fields the brief named:

field (current) after source at render
wopcRef KEEP β€” (the single hook)
referenceNumber drop pure dup of wopcRef
payee drop WOPC.contractor.name
payeeAbbreviation drop WOPC.payee.abbreviation
payeeId drop WOPC.payee.abbreviation (same value)
payeeBankIdentifier drop label for the bank block; WOPC.bank carries the concrete account (verify no reader)
country,addressLine1/2/3,region drop WOPC.contractor.address
bankName,bankCode,bankAccountNumber,bankAccountHolderName drop WOPC.bank.*
presenterWorkType drop WOPC.lineItems[].projectSnapshot.presenterWorkType
clientCompany drop entirely not shown for 5050 (owner decision)
relatedProjectId drop (unconditional) WOPC.lineItems[].projectSnapshot.projectId (multi-project safe)

After: gl['5050'] = { wopcRef: "ERL-WOPC/2026-007", …own-facts only } β€” 17 keys β†’ 1 hook.

2026-07-27 β€” relatedProjectId "no-WOPC" case investigated β†’ CONFIRMED absent β†’ diff extended

Owner: "I don't recall having any tx that explained by a GL account with direct use of relatedProjectId. Look into the repo … show me a real example. … if you confirm no such case, then extend your structure gate diff." Findings (data + code): - Data: 30 gl nodes carry relatedProjectId, all GL 5050, all with wopcRef; ZERO without a WOPC (probe over all 184 tebs-erl transactions). - Code β€” no reachable direct writer: - The projectLink workflow module (would set relatedProjectId via TransactionLinkingModal:612) is an unimplemented stub β€” WorkflowModuleRenderer.tsx:289 renders "not yet implemented" β€” and appears in no workflow template, so data.projectLink is never populated. - StaffReimbursementForm (writes relatedProjectId directly) is exported but mounted nowhere β€” forthcoming T-178 reimbursement UI, not wired, partly stubbed. - The only LIVE persisted writer is the WOPC save-payment-confirmation path ([id].ts:1219), which DERIVES relatedProjectId from the WOPC's line items β€” hence every row is a WOPC 5050 row. - Decision: the "direct project-expense, no WOPC" case does not exist (data or reachable code), so relatedProjectId is retired UNCONDITIONALLY and resolved per-line-item from the WOPC. The don't-touch caveat is moot. Forward-compat note pushed to T-192: T-178's reimbursement path must put project linkage on the WOPC/reimbursement doc, not a tx copy.

Structure gate now: gl['5050'] collapses 17 keys → { wopcRef }. Awaiting owner approval to implement (expand→migrate→contract: WOPC-first read + index → backfill-verify rendered tokens + report totals old-vs-new → contract the copies, re-running this gate before the destructive step). - Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” step-1a landed (index + batch resolver + prefetch) + a live NON-DETERMINISM bug found

  • Step 1a (this commit, non-destructive): firestore.tebs-epl.indexes.json COLLECTION_GROUP index on WOPC.refNumber (wired in firebase.json); getWOPCByReferenceNumber reimplemented as a single collectionGroup query (was an all-payees scan β€” fixes ~15 call sites), with a loud-logged scan fallback if the index isn't deployed; new getWOPCsByReferenceNumbers batch resolver; enrichTransactionDisplayNames now pre-fetches every page's WOPCs in one bounded lookup and hands each row its view (kills the per-row N+1). tsc clean; suite 750 pass (the 4 workspace/billing/ingest failures are the pre-existing Project-Id env flakes, unrelated).
  • DISCOVERY β€” enrichment is non-deterministic today. A parity harness (real enrichTransactionDisplayNames over all 34 WOPC rows) produced different output run-to-run on identical code: a 5050 row renders invoiceNumber as #ERL-2026-002-0319 + clientCompany "Raw Harmony Limited" + projectYear on some runs, and ERL-2026-002-0319 with NO client on others. Root cause = the exact dual-source race this task targets: under Promise.all, whether projectData (fetched from the relatedProjectId copy) + the project-invoice .limit(1) resolve before the WOPC-derived wopcInvoiceRefs decides which "wins" the token. So the copies don't just go stale β€” they make the live render flip-flop. This both strengthens the task's justification and means exact old-vs-new diffing can't verify it (the baseline is unstable). New acceptance test: the WOPC-first flip must make new-vs-new deterministic (identical run-to-run) AND match the WOPC as ground truth.
  • Open token question for the owner (step 1b): on a 5050 row the old {client} token resolved to the end-client's company (e.g. "Raw Harmony Limited") from the project invoice. A WOPC pays a contractor, not a client β€” so {client}/{clientCompany} should drop for 5050 (consistent with the clientCompany decision). Confirm drop vs. repoint to the payee.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” step-1b landed: WOPC-first flip, render now DETERMINISTIC (verified over all 34 rows)

Owner rule (verbatim): "If a piece of information is stored else where and can be referenced else where, then drop it from the tx itself." β†’ settles the {client} drop and the whole gate.

  • Flip: for a WOPC row, enrichTransactionDisplayName skips the project-doc + project-invoice reads entirely and sources payee / contractor / bank / address / project title-worktype-nature / invoice number(s) SOLELY from the attached WOPC view; drops client / clientCompany / projectYear / region (referenceable elsewhere and not on the WOPC).
  • Verification (real enrichTransactionDisplayNames over all 34 WOPC rows, parity harness): new-vs-new byte-identical β†’ deterministic (the pre-existing race is gone); 34/34 render, 0 blank, 0 undefined/[object] anomalies. tsc clean; suite 750 pass (4 pre-existing workspace/billing/ingest env flakes).
  • Regression caught + fixed during verification: the first flip blanked the invoice number on 2 rows (WOPC 2024-005, 2025-001). Root cause = extractInvoiceRefsFromLineItems's regex only matches PREFIXED (ERL-2026-…) refs, missing the legacy UNPREFIXED (2024-016-1025-b) ones on older WOPC line items (the T-168 prefix split again). Fixed by reading lineItems[].invoiceItemRef.invoiceNumber directly (both conventions), prefix-normalized β€” still strictly WOPC-sourced, no project lookup. Now even more precise than before (ERL-2024-016-1025-b keeps the -b variant the old project-first lookup dropped).
  • Net render change vs the old (nondeterministic) baseline: invoice numbers show the WOPC's own ref (ERL-2025-012-0730, no #/reformat) and client-company is dropped β€” the intended WOPC-authoritative result. Still EXPAND phase (copies remain on the docs, just ignored); CONTRACT (strip the fields + stop the writers) comes after owner sign-off, re-running the structure gate.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” invoice-home question settled (tebs-erl, all ERL) + CONTRACT landed (code)

  • Invoice-home Q (owner): Project Invoices live on tebs-erl (32 invoices; tebs-epl has none), and all 32 doc-ids are already ERL--prefixed (2024–2026) β€” matching the DB. New invoices generate ERL-prefixed too. So no renumber: EPL would misattribute Records' invoices to Publishing and mismatch storage. The "unprefixed" numbers seen earlier were WOPC line-item COPIES, not invoices β€” normalized at render by step 1b.
  • CONTRACT β€” reader-redirect (the safety-critical piece). Audit found the retired copies had LIVE readers beyond enrichment: the details-drawer bank block (expenseMetadata.bankName/…, no fallback), the display-name generator + token resolver (relatedProjectTitle), the workspace hook. A blind strip would blank them. Fix: the transactions API enriches EVERYTHING via enrichTransactionDisplayNames before the UI, so enrichment now populates the in-memory expenseMetadata with WOPC-derived values (and clears the dropped fields) for WOPC rows β€” every reader is WOPC-sourced transparently, no per-reader edits. Verified: deterministic, and a sample row's expenseMetadata now shows WOPC bank/address/project/payee with clientCompany/projectYear/ region/payeeId/payeeBankIdentifier dropped.
  • CONTRACT β€” write-stop. save-payment-confirmation (transactions/[id].ts) no longer copies project/client/bank/address onto the tx and deletes the racy project-invoice fetch that lived in the write path; stores only wopcRef (+ contractor payee). Retired keys are stripped from the pending assignment so they aren't re-persisted.
  • CONTRACT β€” data strip STAGED (not run). scripts/contract-5050-wopc-copies.ts (dry-run default, --live): removes the 18 WOPC-derived copy fields from WOPC-linked gl nodes, keeping wopcRef/payee/memo/own-facts, gated per-row on the WOPC resolving. Dry run: 34/34 rows, 466 fields, 0 skipped. Must run ONLY AFTER the WOPC-first read code deploys.
  • Open owner call: kept payee (contractor name) on the tx as an at-a-glance field; strict {wopcRef}-only would drop it too. Keep or strip?
  • tsc clean; suite 751 pass (4 pre-existing workspace/billing/ingest env flakes). Deploy order: (1) deploy tebs-epl WOPC.refNumber index, (2) deploy app (read-side), (3) soak, (4) run the strip --live. Coordination: Records (Infra) confirmed T-192 accepted + flagged the same merge-order point (T-191 resolver live before the first 2110 payout).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

Source

Brief authored by another agent and routed by the owner, 2026-07-27; renumbered from T-190 β†’ T-191. - βœ… Read AGENTS.md Β· checked the board by scope (no dup β€” the pasted brief collided with the existing T-190; this is the correctly-numbered home). - Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea

2026-07-27 β€” DEPLOYED + STRIPPED + VERIFIED Β· status β†’ done (owner: "strip payee. proceed with deploy order in one go")

  • βœ… Read AGENTS.md. One-off deploy authorization (this sequence only) honored.
  • Executed, in the safe order:
  • Index β€” created the tebs-epl WOPC.refNumber COLLECTION_GROUP index via the Firestore Admin API (SA had index-admin); verified LIVE (collectionGroup in query returns).
  • Merge β€” PR #946 β†’ main @ 3955d1d7 (squash).
  • App deploy β€” Vercel dpl_3B1cgDvpzQRwC9RRrsMrnZYHXp8p READY on 3955d1d7 (WOPC-first read code live).
  • Data strip β€” scripts/contract-5050-wopc-copies.ts --live: 34/34 rows stripped, 500 copy-fields removed, 0 skipped (every WOPC resolved via the now-live index).
  • End-state verification (post-strip, real data): the sample doc's gl['5050'] is now exactly { wopcRef } (17 keys β†’ 1). All 34 rows still render (0 blank); displayName still shows the contractor, invoice number and project β€” and expenseMetadata.bankName etc. still resolve β€” all derived from the WOPC though the tx stores none of it. The nondeterminism bug is gone.
  • Verdict: the owner's goal is met β€” a WOPC-linked transaction stores only the WOPC reference; everything else is read from the WOPC at render, deterministically. Payee (contractor name) is stripped from storage per the owner's strict {wopcRef}-only call; still displayed (WOPC-derived).
  • Commit SHAs (append-only): c77b736, 3955d1d7 (PR #946), + this close-out.
  • Deploy: DONE (index + app live; data stripped in prod).
  • Blast radius: all WOPC-payout (GL 5050) rows now resolve display from the attached WOPC via enrichTransactionDisplayName (single source); getWOPCByReferenceNumber no longer scans all payees (collectionGroup index); save-payment-confirmation writes only wopcRef. Forward: T-178's 2110 reimbursement rows get this for free if they store only wopcRef (the contract, re-homed off the clobbered T-192 β€” see below).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea