Skip to content

T 178


uid: T-178 title: WOPC pipeline extended to reimbursements (GL 2110) β€” one doc type, pay-with-existing, related-party-only signing status: done area: accounting-wopc created: 2026-07-23 updated: 2026-08-04 owner: girafeev1 assignee: Records (Infrastructure) related: T-089, T-167, T-147, T-024


Why (owner, 2026-07-23)

The owner wants the web app to pay individuals for non-work matters β€” reimbursing staff receipts first, but not exclusively. Rather than a new "Payment Confirmation (PC)" document type (which would collide head-on with the existing internals β€” WOPC already stands for "Work Order/Payment Confirmation" and the code calls it PaymentConfirmation everywhere), the decision is to extend the existing WOPC pipeline with a reimbursement purpose.

"make the idea of our currently discussing PC as part of our existing WOPC pipeline, but just to additionally make it so that we could reference and write receipt information on it and to match tx to 2110 and attach this type of WOPC to the tx instead of making variations of the WOPC (and have it recorded with a slightly different format of reference number etc...?)"

Decisions (owner, this design conversation β€” quoted)

  1. One WOPC type, shared numbering. No new type, no ERL-PC series. Same ERL-WOPC/YYYY-NNN reference for both; a WOPC gains a purpose that routes the accounting: contractor fee β†’ GL 5050 (today) or reimbursement/non-work β†’ GL 2110 (new). Told apart in the UI by a tag, not the reference. (Owner: "Reference=shared".)
  2. Related-party-only signing. Sign + seal only when the WOPC is issued to a related party (a director) β€” for IRD/audit legitimacy. Everyone else (external subcontractors, reimbursements) pays with no signature/seal. Default: "related party" = directors; extend with a payee flag later if other related parties exist.

    "a WOPC technically doesn't need to be signed if the payment is not made as a sub-contractor fee to any party related to our company. The reason why it needs to be signed and sealed only because the sub-contractor fees paid to our director needs to be legitimate for auditing and the IRD eventually."

  3. Unsigned closing mirrors the client invoice email (owner: "make the closing of the WOPC the same as the invoice mail being sent to clients"):
    Kind regards,
    Accounting Team of Establish Records Limited
    On behalf of
    <operator first + last name>
    <position>
    
    "operator" = the web-app user who processes it. If profiles lack a job position/title, add a place to set one. Signed (related-party) WOPCs keep the director signature + chop.
  4. Staff-only payees. "None staff would not have access to the web app" β€” so reimbursement payees are always staff and GL 2110 (Staff Reimbursements Payable) is always the correct account; v1 is receipt reimbursements.
  5. Pay-with-existing + can't-pay-twice marker. Matching a tx must be able to link an existing, not-yet-paid WOPC (today the flows only ever create one inline). "Not yet paid" = not yet matched to a bank tx β€” already encoded by WOPC.status (pending_transaction vs active + transaction.id). Add one lightweight "payment fired" marker to close the window between firing an app payment and the bank tx syncing back (owner: "Sure").
  6. Match to a Vendor Invoice instead of a WOPC (built on top of the above): if the payee already submitted their own invoice, that invoice is the supporting document, so no WOPC is created β€” the tx-match (for both 5050 and 2110) can link an existing WOPC or Vendor Invoice. "Vendor Invoice" is distinct from the "Service Invoices" tab (the Google Workspace/Cloud home). Exact definition + intake to be pinned down when this phase starts.

    "If an invoice has been submitted, then a WOPC is not needed to be created at all. Meaning, when matching a tx to 2110 and 5050, allow user to match the tx to existed WOPCs and Vendor Invoices, which is not be confused with the Service Invoice that we currently home the Google Workspace/ Google Cloud invoices or statements, and it's also something that needs to be built on top on our current plan"

Build order (owner-revised, 2026-07-23 β€” verbatim)

  1. Make it so that the workflow of the matching of a TX to 5050 is able to match with an existing WOPC that's not paid β€” server side, UI (integrated with the 5050 module), and the writing of the Firestore doc.
  2. Referencing the 5050 module, improve (or build) the 2110 matching module following the current module design (customization, no hardcoding).
  3. Integrate the Records page to facilitate WOPC for reimbursement.
  4. The current content of the WOPC for 5050 needs to be revised for the WOPC for 2110 β€” show the owner the current content first, then discuss the revision.
  5. WOPC closing for non-signing (no director assigned) WOPCs and WOPC for receipt reimbursement.
  6. Unsigned "Kind regards" closing for non-related-party; keep sign-then-release for related parties.
  7. The rest: "Pay with…" dropdown on Bank Access, the can't-pay-twice marker, and the Vendor-Invoice matching phase (decision 6).

Key facts from the research (2026-07-23, five Explore sweeps)

  • Matching is a plugin + template architecture (lib/accounting/matchPlugins/, lib/accounting/workflowTemplates.server.ts) built so a new document type is a new plugin + template + capability tag with no host edits β€” the seam for the 2110 module (step 2).
  • 5050 = GL_CONTRACTOR_FEES (lib/accounting/constants.ts:35); 2110 = Staff Reimbursements Payable. The receipt reimbursement journals already exist: incur DR expense / CR 2110, settle DR 2110 / CR bank (lib/telegram/receiptStore.ts; StaffReimbursementForm.tsx) β€” so the reimbursement WOPC must be the single settlement path (don't double-post the payable).
  • WOPC doc = payees/{abbr}/wopc/{id}; tx↔WOPC link = gl.{code}.wopcRef (canonical) + root wopcReferenceNumber; updateWOPCMatched transitions pending_transaction β†’ active.
  • Signing gate today = !!closingDirectorId (director payee β†’ held for signature β†’ release on sign); no director β†’ pays immediately. Seal (chop) is a pdf-lib overlay, NOT a payment precondition. No statutory citation β€” it's a self-imposed governance control (cross-issuance: a director can't sign their own payment).
  • WOPC unsigned template already renders an empty signature slot (lib/paymentConfirmation/PaymentConfirmation.tsx) β€” step 5/6 swaps that for the "Kind regards" block.

Open items to resolve at their phase (not blockers to step 1)

  • Operator position/title source on user profiles (step 5/6) β€” add a field if absent.
  • Vendor Invoice exact definition + how one enters the system (step 7) β€” distinct from Service Invoices.
  • "Related party" beyond directors (step 6) β€” add a payee flag only if the case exists.

Log

  • 2026-07-23 β€” opened (doing). Design settled over a multi-turn discussion; build starts at step 1. βœ… Read AGENTS.md Β· checked the board by scope (T-089 = WOPC doc-shape, T-167 = Pay-with-WOPC v2, T-147 = receipt reimbursement accounting β€” this extends all three into the 2110/reimbursement flavour; no covering task). Collision check: T-177 taken (Apps Script) β†’ this is T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
  • 2026-07-23 β€” step 1a: server foundation (design preview approved by the owner: "Okay, proceed", after a clickable mockup of the fork β†’ unpaid-WOPC picker β†’ confirm-&-link). Shipped two endpoints + a one-line core tweak, all reusing the existing auto-match linker so there is NO second, drifting link path:
  • pages/api/accounting/wopc/unpaid.ts (GET) β€” the picker's pool: WOPCs still pending_transaction (no transaction.id), not voided/deleted, lean picker fields incl. a derived signingStatus (signed / awaiting-signature / unsigned).
  • pages/api/accounting/wopc/link-transaction.ts (POST {referenceNumber, transactionId, allowAmountMismatch?}) β€” manual link; fetches the tx, resolves the contractor-fees GL from settings, and calls matchOneWopcTransaction (byte-identical GL assign + tx↔WOPC backref + pending_transactionβ†’active + notification as the auto path).
  • lib/accounting/wopcAutoMatch.server.ts β€” matchOneWopcTransaction gains allowAmountMismatch? (auto path never sets it; the manual UI passes it only after the operator confirms a shown mismatch). Gate: full unfiltered tsc --noEmit --incremental false clean on the project's TS 5.9.3 (had to npm install first β€” node_modules was absent and npx pulled TS 6 which errors on the repo's moduleResolution=node10/baseUrl). Next (step 1b): the 5050-module UI β€” the fork + picker + confirm β€” calling these two endpoints. Blast-radius: additive only β€” two new endpoints + one optional param. The auto-match path is unchanged when allowAmountMismatch is unset. No schema change; no UI yet. ⚠️ Repo hygiene handoff (not mine): package-lock.json on main is OUT OF SYNC with package.json (missing esbuild@0.28.1, yaml@2.9.0 + platform variants) β€” npm ci refuses, which will break Vercel's clean-room install on the next deploy. Needs a lockfile reconcile (npm install) in its own PR by whoever added the dep. Flagged, not fixed here. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
  • 2026-07-23 β€” step 1b: the 5050-module UI β†’ Phase 1 DONE. The "use an existing unpaid WOPC" flow is wired end-to-end. All new UI lives inside useWopcInline (lib/accounting/matchPlugins/ wopcInline.tsx) so the host modal needed only one new optional prop:
  • Fork (antd Segmented, default create β†’ today's flow untouched): "Create a new WOPC" | "Use an existing unpaid WOPC".
  • Picker takeover β€” fetches GET /api/accounting/wopc/unpaid, floats + auto-selects the amount-matching WOPC (mirrors BillingLinkModal), radio single-select, signing-status tags + closing-director name (getDirectorById), search, empty state β†’ "create one instead".
  • Confirm-&-link takeover β€” chosen WOPC vs the tx, a green (match) / amber (mismatch) Alert, "Confirm & Link" (matched) or "Link anyway" (mismatch β†’ posts allowAmountMismatch:true) β†’ POST /api/accounting/wopc/link-transaction.
  • Host (TransactionLinkingModal.tsx): +onLinked? prop on useWopcInline; on success it re-fetches the tx via GET /api/accounting/transactions/[id] β†’ onRefreshTransaction β†’ onClose (mirrors the create path). Gate: full unfiltered tsc --noEmit --incremental false clean. Owner runtime-verify (after deploy): match a 5050 debit tx β†’ the fork β†’ "Use an existing unpaid WOPC" β†’ amount-matched WOPC highlighted/auto-selected β†’ Continue β†’ Confirm & Link β†’ row shows the WOPC ref + GL 5050 and it leaves the picker. Create-new (default) unchanged. Blast-radius: Telegram/receipt untouched; the accounting match modal's WOPC (5050) path only β€” additive fork + two new takeover states + one host prop. No 2110/Records/document changes (Phases 2–7). SHAs: cac8871 (1a) + the 1b code commit + this doc commit. Deploy: owner granted a standing deploy order for T-178 ("deploy each time things require deploying to function, for the entire task") β€” 1b is UI so it deploys; prerequisite = reconcile the lockfile drift above first (Vercel npm ci). Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
  • 2026-07-24 β€” Phase 2: GL 2110 reimbursement matching. Accounting model confirmed with the owner (verified in code): the 2110 payable is derived from receipt metadata, never posted directly β€” incur DR [category] / CR 2110 on receipt‑confirm (derivedJournals.server.ts:769), settle DR 2110 / CR bank when the receipt records which bank tx paid the employee (:835, driven by settledBankTxId). The expense always lands in its category GL in both pipelines (company‑card = DR category / CR bank at match via categorizeTxFromReceipt, which skips reimbursements). So a direct GL‑2110 match on a bank tx would double‑post the settle leg β€” which is why 2110 must go through the receipt/settle flow, not the GL dropdown. Shipped:
  • components/accounting/transactions/TransactionLinkingModal.tsx β€” GL 2110 excluded from the "GL Account" dropdown, mirroring the existing 4000/4001 match‑only exclusion (:2739).
  • components/accounting/ReimbursementsTab.tsx β€” the Settle modal's free‑typed bank‑tx id is replaced with a picker: fetches the subsidiary's debit transactions (GET /api/accounting/transactions), floats + auto‑selects the one matching the receipt total, radio single‑select, plus a "settle without linking (credits default bank 1000)" option. Still POSTs the existing /api/receipts/[id]/settle (single settle path β€” the DR 2110 / CR bank leg stays derived, written once; nothing double‑posts). Gate: full unfiltered tsc --noEmit --incremental false clean. Scope decisions: the optional server‑side assign-account guard for 2110 was dropped (owner declined; the dropdown exclusion covers the UI). The tx‑side "reconciled" marker on the settled payout tx is deferred β€” it needs care to avoid a different double‑post (categorizing that tx to a GL on top of the derived settle) and is a pre‑existing gap, so it gets its own pass. Open (owner): the settle picker is per‑receipt (matches today's granularity); a per‑employee/group settle (one payment clears all of a person's open receipts) is an easy follow‑up if the owner wants it. Owner runtime‑verify (after deploy): GL 2110 no longer appears in the Match Transaction "GL Account" dropdown; on the Reimbursements tab, Settle now lets you pick the payout transaction (no typed id), with the amount‑matching one floated + auto‑selected. Blast‑radius: the Match modal's GL dropdown + the Reimbursements‑tab Settle modal only. No change to the journal generator, the settle endpoint, or the receipt store. SHAs: the Phase‑2 code commit + this doc commit. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

2026-07-25 β€” WOPC document layer rebuilt: measured restyle + pagination + purpose/closing forks

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

What changed. The owner asked to "wrap up the entire WOPC in one go", so the document layer (phases 0/A/B of the revised order) landed together rather than in three PRs.

Why the geometry is now measured, not designed. The owner supplied a Google Doc template (ERL_PaymentConfirmation, three tabs: One Pager / Two Pager / Paginated) and asked us to "mimic the spacing as much as possible (study font size, line space and row space in the different document tabs)". Rather than eyeball it, the Doc was exported to PDF and every element's font, size and glyph-box top read out with PyMuPDF, then transcribed into lib/paymentConfirmation/layout.ts. The odd fractions in that file (28.05pt row pitch, 282.05pt table origin, 15.7pt body leading) are measurements β€” do not round them; the document is compared against that template by eye.

Key facts the measurement established: - The template migrated a lot of type from EB Garamond β†’ Newsreader: Date/Ref (Light 10pt), the whole recipient block (Medium/Bold/Light 11pt at 11pt pitch), table header (Regular 12pt), item title (Bold 12pt) and fee/category (Regular 9pt). Body and closing stay EB Garamond. - Row pitch is 28.05pt for a single-line row and 32.25pt for a two-line row. The two correlate with purpose, not page count: the One Pager tab is a reimbursement (single-line, 14pt amounts), the Two Pager and Paginated tabs are contractor fees (two-line, 16pt amounts). - The Date/Ref block shifts up 10pt when the document paginates to make room for "Page x of y". - A one-page WOPC holds about 5 two-line rows β€” measured, correcting an earlier estimate of 11–12 in this conversation that had assumed minimum-height rows. That is why the old single-page lock was a real defect, not a theoretical one.

Pagination is a fixed-count rule, deliberately NOT a content-height engine. The owner specified exact counts β€” one-pager ≀ 9 items + total; two-pager 10 then 10 + total; 3+ pages 10 first, 15 per middle page, 10 + total last β€” which turns a measuring problem into arithmetic. The project invoice's lib/invoiceTemplates/paginationEngine.ts was therefore not reused. Row heights are fixed and the description cell clips (overflow:hidden) so a long merchant name cannot silently push content off the sheet.

Closing fork. No stored requiresSigning flag exists β€” signing is opt-in per WOPC via a signing request β€” so the fork is derived: related-party payee (a director) β†’ signed and sealed ("Yours sincerely," + signature slot + chop, unchanged); everyone else β†’ unsigned ("Kind regards," / For and on behalf of / Establish Records Limited / operator name / operator title). This covers reimbursements and subcontractor fees paid to non-related parties, per the owner. Rendering a signature image always forces the signed variant so the signing pipeline cannot land in the wrong mode, and closingMode overrides both.

Owner's reasoning for dropping the "Accounting Team of Establish Records Limited" entity, verbatim (2026-07-25): "an accountant seems wrong… If it's an accountant, shouldn't it be the accountant on behalf of the Accounting Team? … do you think it's weird to involve the 'Accounting Team of Establish Records Limited' as an entity?" β€” agreed and dropped: a department is not a signing entity, the company is, and naming it forced a direction that inverted by role. Owner then chose the company-level formula: "For the WOPC, let's do '2 β€” Company-level formula'".

Two real defects found and fixed on the way: 1. Every italic in every WOPC ever issued was fake. The Google Fonts URL requested EB+Garamond:wght@400;600;700 with no ital axis, so the browser synthesised an oblique by slanting the roman instead of loading the genuine cursive italic. Affected the closing title and the Paid on β€Ήdateβ€Ί line. Fixed in both copies of the URL (lib/wopc/pdfRenderer.server.ts and the preview page) β€” they must stay in sync. 2. React escapes text inside <style>, so a > in a CSS child combinator becomes &gt; and the rule silently dies under renderToStaticMarkup β€” the path the PDF renderer uses. The pre-T-178 component carried exactly this bug in its .payment-confirmation-page > * { flex-shrink: 0 } rule. The stylesheet is now injected via dangerouslySetInnerHTML and positioning is declared per-class. 3. A unit test caught a third: filling page one to its 10-row cap when there are exactly 10 rows left page two holding zero items and just a Total. The two-page split now guarantees the last page keeps at least one row (10 rows β†’ 9/1); the owner's canonical 20 β†’ 10/10 and 35 β†’ 10/15/10 are unchanged.

Storage question, settled. Signed WOPCs are composed with signature + chop and uploaded to Google Drive at signing (pages/api/wopc-signing/[requestId]/sign.ts) β€” those archived PDFs are immutable and a restyle cannot touch them. Unsigned WOPCs are never persisted: lib/storage/wopc.ts is dead code with no callers, and every unsigned PDF (download, email attachment, preview, thumbnail) is re-rendered on demand from Firestore. Owner confirmed this is by design, verbatim: "unsigned or 'no need to be signed' WOPC are never exported and stored because they only needed to be computer generated right on the spot with the information stored".

Verification. Unfiltered tsc --noEmit --incremental false clean. vitest run β€” 687 passed, 76 files; the only failures in the repo (4, in __tests__/pages/api/workspace/billing/ingest.test.ts) were confirmed pre-existing by re-running them on a stashed clean HEAD. The real component was server-rendered at 9 / 14 / 20 receipts and measured in headless Chromium: page counts, per-page roles, page-number presence, and A4 fit all correct, with the table foot at 588.9pt against the template's 588.25pt and the payment block at 600.2pt against the template's 601.8pt.

Blast radius β€” read this if you work near the WOPC. lib/paymentConfirmation/PaymentConfirmation.tsx was rewritten, so all six of its consumers now render the new geometry: the PDF renderer, the preview page, the transaction thumbnail endpoint, ToolsApp, wopcInline, and TransactionLinkingModal. Contractor WOPCs are restyled too, not just reimbursements β€” and any non-related-party contractor WOPC now closes unsigned where it previously printed a director block. lib/pdfTemplates/paymentConfirmation.tsx remains dead (no importers) and was left untouched.

Still to come on T-178: the Reimbursements tab rework (per-employee lenses, checkboxes, sticky action bar, confirmation screen), the tx-side settledReceiptIds marker, and Records integration.

2026-07-25 β€” Dynamic type scale + a one-pager overflow bug the first pass shipped

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The bug. The measured restyle dropped contractor item titles from 14pt to 12pt (and fee types 11pt β†’ 9pt) because that is what the owner's template tab showed. Asked how that affected contractor WOPCs, a re-measure found something worse than a type change: a 9-item contractor one-pager put its payment block straight through its own closing β€” payment block bottom 684.6pt against a signed closing starting at 650.6pt, a 34pt overlap. The first pass's fit check only compared table bottom vs payment top, and only on reimbursements, so it passed a document that was visibly broken.

Root cause, and it is worth remembering. The owner's "one-pager holds 9 items" rule was calibrated on reimbursements, whose single-line rows are 28.05pt β€” nine of those fit exactly. Contractor rows are two-line and 32.25pt; nine do not fit. The one-page cap is purpose-dependent and treating it as one number is what broke it.

Measured capacity (payment block spans 42.4pt; closing top 671.2pt unsigned / 650.6pt signed β€” signed is taller by 20.6pt and therefore binds; 24pt of air kept between them; 12pt above the footer rule at 769.2pt):

Page role Full type (14/11, 36pt rows) Reduced (12/9, 32.25pt) Owner's spec
One-pager, contractor 6 7 9 βœ—
Paginated first page 11 13 10 βœ“
Middle page 15 17 15 βœ“
Last page 10 12 10 βœ“

The headline finding: the owner's whole multi-page shape (10 / 15 / 10) fits at FULL type size. So contractor WOPCs get their 14pt titles back the moment they paginate. Only a crowded one-pager ever needs the smaller type, and only for a single row's worth of overflow.

What shipped (planWopcPages): try one page at full type β†’ else one page at reduced type β†’ else paginate at full type. Concretely, a contractor WOPC is full type at ≀6 items, steps down to reduced at exactly 7, and from 8 onward paginates back at full type. Reimbursements keep their single size and their nine-receipt one-pager exactly as specified β€” their rows are already one line, so there is nothing to step down from.

Two-page splits are now even rather than front-filled: 10 rows became 10/0 (a page holding nothing but a Total) and 11 became 10/1. They are now 5/5 and 6/5. The canonical 20 β†’ 10/10 and 35 β†’ 10/15/10 are unchanged.

The guard that was missing. maxRowsWithClosing / maxRowsToFooter compute what the geometry actually allows, and the test suite now asserts β€” exhaustively over 1–120 rows, for all three purpose/closing combinations β€” that the payment block never reaches the closing and no table runs into the footer. That is the check whose absence let the overlap ship; it would have failed loudly.

Verified. tsc clean; vitest 702 passed. Rendered through the real component and measured in headless Chromium: contractor 6 items β†’ 14pt on one page; 7 β†’ 12pt on one page; 9 signed β†’ two pages at 14pt with no overlap (previously the 34pt collision); 35 β†’ 10/15/10 all at 14pt with 38pt of footer clearance; reimbursement 9 β†’ unchanged single page at 28.05pt rows.

Blast radius: contractor WOPCs of 1–6 items and any paginated contractor WOPC now render in larger type than the first T-178 pass produced β€” closer to the pre-T-178 sizing. A 7-item contractor one-pager is the only case that renders in the reduced type. Reimbursements are untouched.

2026-07-25 β€” Table returns to the original single-rule border

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The measured template draws a full cell grid, and the T-178 restyle faithfully reproduced it. Seeing it beside the old document the owner preferred the original lighter treatment β€” verbatim (2026-07-25): "For the new WOPC, I'd like the table border to look exactly like the old one. With just one bottom border under the title row." Cell borders removed; a single 0.5pt #d0d0d0 hairline now sits under the header row only, matching the pre-T-178 .pc-table-header rule. This is a deliberate departure from the measured template β€” please don't "restore" the grid on the grounds that the Doc has one.

Owner asked for it in the next phase; it landed in the open T-178 draft PR instead, because it is a three-line change to the very file that PR rewrites and merging a table the owner had already rejected would have meant a follow-up PR over the same lines.

Removing the grid exposed a second thing: "Amount (HKD)" wrapped onto two lines. Newsreader is wider than the Times fallback the old server render was silently using, and the header text needs ~88pt against a 77.6pt content box. It now runs left into the (empty) multiplier column via white-space: nowrap, which is what the template itself does β€” its header text starts at x453.8 while the column begins at x454.

2026-07-25 β€” Why the restyle looked bigger than it was: the PDF had NO fonts

Comparing old against new side by side (both rendered through renderToStaticMarkup, the PDF path, with real webfonts available) produced the finding that reframes this whole phase:

Every WOPC PDF was rendered in Times New Roman. React escapes text children, so the old component's server-rendered <style> came out with every quote turned into &#x27; β€” font-family: &#x27;EB Garamond&#x27; is not valid CSS, so the browser discarded all 39 font-family declarations. No EB Garamond, no Cormorant Infant, no Federo, and the "E." letterhead mark was not Rampart One either.

It only ever bit the server path, which is why it survived so long: the on-screen preview renders in the browser, where React sets the style element's text content directly and nothing is escaped. The preview always looked right; the PDF never did. Affected lib/wopc/pdfRenderer.server.ts and pages/api/accounting/transactions/[id]/wopc-thumbnail.ts.

Consequence for the record: signed WOPCs already archived to Drive were produced by that pipeline, so those stored PDFs are in Times New Roman. They are immutable and unaffected by this work, but any document re-rendered from now on will not match its archived copy.

So of eighteen elements compared, only seven are genuine design changes (the recipient block, Date/Ref and all table text moving EB Garamond β†’ Newsreader, per the updated template). The rest were declarations that had never taken effect and now do.

2026-07-25 β€” Reimbursements tab reworked (owner items 3a.i / 3a.ii / 3a.iii)

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

3a.i β€” the lens moved down a level. The top control used to switch Open / Settled / Unmatched across everybody. It now switches the shape of the list β€” By employee or All receipts β€” and each employee carries their own three lenses. Rationale (owner): a Payment Confirmation is issued to one payee, so "reimburse" only ever makes sense within a single person. settled and unmatched arrive from the API as flat lists and are grouped client-side, so no API change was needed.

3a.iii β€” checkboxes replace the per-receipt Settle button. Implemented with Ant's rowSelection, which brings select-all for free. Settled and Unmatched lenses stay read-only (Undo still lives on Settled); only the Open lens is selectable. A sticky action bar carries the running count, total and employee, and appears only once something is ticked.

3a.ii β€” "Reimburse" is now a submit, not a hand-off. The old button deep-linked to Bank Access with a fabricated reference ("Reimbursement Jeffero Chan") and let the operator confirm a payment having seen neither the receipts nor a document β€” the owner's specific complaint. It now opens a confirmation with β‘  the selected receipts and their total, β‘‘ the payout bank-transaction picker (reused from Phase 2, now matching against the selection total rather than one receipt), and β‘’ a live preview of the Payment Confirmation, rendered through the very same component the PDF uses so preview and artifact cannot drift.

Selection is scoped to one employee, deliberately. Ticking someone else's receipt starts a fresh selection rather than silently merging two people into one document. In the All-receipts view that is enforced by keeping only the rows matching the newly-added row's employee, with a quiet notice.

What Confirm does β€” and does not β€” do. It settles every selected receipt through the existing POST /api/receipts/:id/settle, so the DR 2110 / CR bank leg stays derived and is written once. It does not yet persist a WOPC document: createWOPC requires a payee record at payees/{abbr} and mints its reference from the global sequence, so issuing a reimbursement WOPC means every reimbursable employee must exist as a payee. That is an owner-facing data decision, so the button is honestly labelled "Confirm & record reimbursement" rather than "issue". The preview is genuine; the persistence step is the next piece of work.

Open item for the owner: the unsigned closing prints the operator's name from their session and the title is hard-coded to "Accounting" β€” there is no position field on the user profile to read. Either a position gets added to the profile, or we settle on a fixed title.

Verified: unfiltered tsc clean Β· vitest 702 passed Β· eslint clean on the changed file. Blast radius: components/accounting/ReimbursementsTab.tsx only. No API, journal-generator, settle- endpoint or receipt-store change. The tab now imports the WOPC component for its preview, which is the first client-side consumer of the T-178 purpose / closingMode props.

2026-07-25 β€” Operator title comes from Individual.system.position; the payee question, corrected

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Correction to the previous entry. It framed "do employees become payees?" as an open owner-facing data decision. That was under-researched. The bridge already exists and the question is narrower than stated. The actual model is three records, two of which are already synced both ways:

  • individuals (aote-system) β€” the canonical person in Contacts. Carries abbreviation (the same UPPERCASE key payees use), legal.bankAccounts, system.userUid (bridge to the RBAC account) and system.position.
  • users β€” the auth/RBAC account. A reimbursement receipt stores uploadedBy = email; the reimbursements API resolves the display name through getUserProfileByEmail.
  • payees β€” the abbreviation-keyed record createWOPC requires.

Individuals ↔ payees already write through in both directions: syncIndividualFromPayee (lib/individuals/payeeSync.server.ts:24) and syncPayeeFromIndividual (lib/individuals/crud.server.ts:159).

So what genuinely caused the question: createWOPC(abbreviation, …) reads payees/{abbreviation} and throws when absent, and generateNextWOPCNumber(year, abbreviation) derives the reference from that abbreviation β€” while a receipt only carries an email. There is no email β†’ abbreviation hop in the reimbursement path. The hop is available (individuals holds both system.userUid and abbreviation); it simply has never been walked, because contractors were always payees and employees were always users and the two pipelines had not met. The remaining prerequisites are therefore narrow: each reimbursable employee needs an Individual carrying an abbreviation and a userUid link, and bank details for the "Paid on … to:" block β€” which already live at legal.bankAccounts.

Operator title β€” no new field was needed. Owner: "do not hardcode it. Instead, integrate each individual that are employees to have a title for rendering and for possible display on the UI." That field already exists: Individual.system.position, documented in the type as the "Display job label ('Auditing Accountant', 'Bookkeeper', …) shown on the contact card". Adding a title to the RBAC user profile would have created a second job title to keep in step, so instead:

  • new self-scoped GET /api/profile/me returns { email, displayName, position }, resolving the caller's Individual by system.userUid. No id parameter, so it cannot read anyone else's record;
  • the Reimbursements tab reads it and passes position as the closing's title;
  • the WOPC component now omits the title line entirely when none is supplied rather than falling back to directorTitle β€” printing "Director" under an accountant's name is worse than printing nothing.

position is already editable through Contacts, so the owner-facing UI for setting it exists.

Verified: tsc clean Β· vitest 702 passed Β· eslint clean on all three changed files.

2026-07-25 β€” Reimbursement WOPCs are now issued for real

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Walks the email β†’ Individual β†’ abbreviation hop identified in the previous entry, so Confirm mints a genuine document instead of only settling.

lib/accounting/reimbursementPayee.server.ts β€” resolveReimbursementPayee(email, by, {ensurePayee}): receipt.uploadedBy β†’ getUserProfileByEmail β†’ individuals where system.userUid β†’ individual.abbreviation β†’ payees/{abbreviation}. The payee is materialised by calling the directory's own write-through syncPayeeFromIndividual, not by minting a payee here, so a reimbursement payee is indistinguishable from one created through Contacts. It also lifts the payment block off the Individual: legal.legalName is a {title, firstName, lastName} triple (not a string), and bankAccounts[].bankName is a [fullName, bankCode] tuple with accountHolderNames as its own list β€” the account holder can legitimately differ from the payee.

Failures throw ReimbursementPayeeError with one of three reasons β€” no-user, no-individual, no-abbreviation β€” surfaced to the operator verbatim as a 409, because all three are fixed by editing the person in Contacts, not by changing code.

POST /api/accounting/reimbursements/issue β€” validates every receipt is a confirmed, unsettled reimbursement belonging to one submitter, then createWOPC with no closingDirectorId (an employee is not a related party, so the document closes unsigned). Returns the reference number from the shared ERL-WOPC series.

It deliberately does not settle. Settlement stays with POST /api/receipts/:id/settle, the single writer of settledAt/settledBy/settledBankTxId β€” the fields the derived DR 2110 / CR bank leg reads. One settle path is what guarantees that leg is written exactly once. The client therefore issues first, then settles: if settling fails part-way the document exists and the rest can simply be settled again, whereas the reverse order would leave settled receipts with no document.

Receipts are read through getTelegramReceipt so the guards see exactly the shape settle will act on (metadata.parsedReceipt, metadata.reviewState, metadata.paymentMethod, metadata.settledAt) rather than a hand-rolled Firestore read against a guessed collection.

Verified: tsc clean · vitest 702 passed · eslint clean on all three files. Known follow-up: the receipt does not yet store the issued wopcRef, so the receipt→document link is one-way (the WOPC lists the receipts; the receipts don't name the WOPC). Worth adding to settle's metadata when the tx-side marker lands.

2026-07-25 β€” Firestore document structures touched by T-178

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Per the new AGENTS.md rule (owner, 2026-07-25) β€” which is an approval gate: a structure change must be put to the owner as current-vs-after and approved before the code is written. This entry is the record of that shape, not a substitute for asking. Legend: + added Β· βˆ’ removed Β· ~ changed Β· unmarked = untouched.

Headline: T-178 has added no new Firestore fields. Everything below is either read, or created in the existing shape. The one genuinely new field is still unbuilt and is listed last.

1. payees/{abbreviation}/wopc/{docId} β€” WRITTEN (new documents, existing shape)

Database: the subsidiary DB (tebs-erl). Doc id is the ref number with / β†’ |. Bucketed T-089 shape:

WOPC: { refNumber: string, issuedDate: Timestamp, paymentDate: Timestamp,
        totalAmount: number, currency: string, closingDirectorId: string,
        status: 'active' | 'pending_transaction', currentRevision?: number,
        created: { at, by }, notes?: string,
        pdf?: { fileId?, signedFileId?, storagePath? },
        signature?: { at, requestId, by, source }, void?: {...}, deleted?: {...} }
bank:        { name?, code?, accountNumber?, accountHolderName? }
contractor:  { name: string, address?: { line1?, line2?, line3?, country? } }
payee:       { name: string, abbreviation: string }
transaction: { id?: string, date?: Timestamp }
lineItems:   unknown[]
Reimbursement WOPCs use this unchanged. They are distinguished only by content β€” closingDirectorId is left unset (an employee is not a related party, so the document closes unsigned) and notes reads "Reimbursement of out-of-pocket expenses". No new field marks a WOPC as a reimbursement; if that distinction ever needs to be queryable rather than inferred, it needs a real field and this is where it goes.

2. payees/{abbreviation} β€” WRITTEN indirectly, no new fields

Created on demand by the directory's existing syncPayeeFromIndividual write-through, so a reimbursement payee is byte-identical to one made through Contacts. T-178 adds nothing to it.

3. individuals/{id} β€” READ ONLY (database aote-system)

The canonical person record. T-178 reads, never writes:

abbreviation: string            ← becomes the WOPC ref prefix
legal.legalName: { title, firstName, lastName } | null   ← NOT a string
legal.bankAccounts: [{ identifier, bankName: [fullName, code] | null,
                       accountNumber, accountHolderNames?, isDefault }]   ← bankName is a TUPLE
system.userUid: string | null   ← the bridge from users/{uid}
system.position: string | null  ← the operator's job title in the unsigned closing

4. users/{uid} β€” READ ONLY (database aote-system)

Only uid, email, displayName are read, to hop email β†’ uid β†’ Individual. No title was added here on purpose: Individual.system.position already exists, and duplicating it would create two job titles to keep in step.

5. Receipts (Telegram receipt store) β€” READ ONLY in T-178's new code

The issue endpoint reads metadata.parsedReceipt.{merchant,category,transactionDate,total,currency}, metadata.paymentMethod, metadata.reviewState, metadata.settledAt, and root uploadedBy. The settle route continues to be the only writer of metadata.settledAt / settledBy / settledBankTxId β€” the fields the derived DR 2110 / CR bank leg reads. T-178 changed none of that.

6. Bank transactions accounting/transactions/entries/{id} β€” READ ONLY so far

transaction: { status: 'unmatched'|'matched'|'partial'|'categorized',
               transactional: { date, amount, isDebit, currency, bankAccountId, ... }, ... }
gl?:         { [accountCode]: GLAccountData, coaching?, coachingInvoices?, receipts? }
notes?: string
receiptIds?: string[]
Derived, not stored β€” the trap: there is no accountCode field. It is computed at read time from the key of the gl map by getAccountCodeFromGL. Writing gl['2110'] on a payout transaction would therefore make the journal generator emit a second DR 2110 / CR bank on top of the receipt-derived one. That is why GL 2110 is excluded from the Match-Transaction dropdown and why the marker below must set no GL key.

7. PROPOSED β€” awaiting owner approval β€” marking a payout transaction as a settled reimbursement

Full current document (accounting/transactions/entries/{id}, database tebs-erl) is at Β§6 above. Under the AGENTS.md gate this is put to the owner as whole-document current-vs-after, with the existing fields considered and rejected, before any code is written.

Existing fields considered first: - gl.receipts (ReceiptAllocations) β€” journal-inert (a reserved key getAccountCodeFromGL skips), but it means "this transaction IS the expense, evidenced by these receipts" β€” the company-card semantics. Using it for a reimbursement payout would assert the opposite of what happened. - gl.{code}.attachments: string[] β€” requires a gl.{code} key to exist, and any such key makes the generator emit DR {code} / CR bank, double-posting the derived settle. Ruled out. - notes: string β€” free text; carries no structure to query or render from. - receiptIds: string[] (doc root) β€” the closest fit by far. Already exists, already at the root (invisible to every journal generator), already surfaced by the adapter. Its current meaning is "documents attached to this transaction" (the vendor-invoice path). Reusing it would mean a reader cannot tell whether a receipt id there is supporting evidence for this transaction or a receipt this transaction reimbursed β€” two different claims about the money.

The proposal, shown in place under its immediate parent (the document root):

{                                       ← document root
  transaction: { … }                    unchanged
  gl?:         { … }                    unchanged
  notes?:      string                   unchanged
  receiptIds?: string[]                 unchanged
+ settledReceiptIds?: string[]          ← NEW, sibling of receiptIds
}
Root level, deliberately: every journal generator reads gl, so a root field cannot double-post. It is display/reconciliation only β€” without it a payout transaction that reimbursed an employee reads as "unmatched" in the ledger forever, because settlement is recorded solely on the receipt.

The owner's call is genuinely open here: reuse receiptIds and accept the overloaded meaning (no schema change at all), or add the sibling above and keep the two claims distinct.

2026-07-25 β€” Scope split: T-178 keeps the PAYMENT pipeline, submission/housing moves out

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Owner, 2026-07-25: "Please, if you feel the need, separate the tasks into multiple more tasks as I feel like this one/ two tasks with so many different things might come off as confusing (it's not a bill passed in the senate)." Agreed β€” T-178 had accumulated the WOPC document layer, the Reimbursements tab, WOPC issuance, an AGENTS.md schema-approval rule, and a proposed restructure of how expense documents are submitted. Only the first three belong together.

T-178 keeps: the reimbursement payment pipeline β€” WOPC document layer (done), Reimbursements tab rework (done), reimbursement WOPC issuance (done), plus the still-open items from the owner's build order: the settled-payout marker (gated on schema approval), Records surfacing, and phase 7's "Pay with…" dropdown / can't-pay-twice marker / Vendor-Invoice matching.

Split out: - T-180 β€” separate Receipts from Service Invoices; stop the unified expense view being a front door. - T-181 β€” Service Invoice upload pipeline (PDF-only, no cropping, no substantiation note, manual auto-match hook). - T-182 β€” declare payment method at submission (Telegram /command + web Review-step flag). This is the one that makes paymentMethod trustworthy, which T-178's discriminator depends on. - T-183 β€” employee payment readiness (abbreviation, legal name, bank account) and prompting for it.

Two facts verified while splitting, both correcting earlier assumptions in this task: 1. Receipts and Service Invoices already have separate tabs; what blurred them is the unified ExpenseRecordsTab / GET /api/expense-records feed. T-180 is largely subtraction. 2. The receipt upload modal already has a Review stage (stage 3, a per-receipt paginator with editable fields) β€” so T-182's web flag needs no new step, just a control in one that exists.

2026-07-25 β€” Phase 7 split out; settled-payout marker decided (reuse receiptIds)

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Phase 7 split (owner, 2026-07-25: "Split Phase 7 in T-178"). It was a catch-all of three unrelated items; each now stands alone: T-184 "Pay with…" dropdown on Bank Access Β· T-185 can't-pay-twice marker on payable documents Β· T-186 match a transaction to an existing Vendor Invoice.

Settled-payout marker β€” decided: reuse receiptIds (owner, 2026-07-25: "Reuse receiptIds"). So no schema change: the transaction's existing root receiptIds: string[] records which receipts a payout settled. The proposed + settledReceiptIds field is withdrawn.

Verified this is safe before proposing it, and again after the decision: - receiptIds sits at the document root, so no journal generator reads it (getAccountCodeFromGL only inspects the gl map) β€” it cannot double-post the derived DR 2110 / CR bank. - It will not hijack the display-name resolver. That picks vendor_invoice on expenseMetadata.matchedFingerprint and receipt on matchedReceipts (i.e. gl.receipts) β€” neither of which a settle would write. See lib/accounting/transactions.ts:1013-1028. - The overloaded-meaning concern that made this a genuine choice ("evidence for a transaction" vs "receipts this transaction reimbursed") is materially reduced by T-182, which turns paymentMethod into a submitter declaration rather than a consequence of matching. A reader can therefore tell the two apart from the receipt itself.

Still to implement (deliberately not rushed): the write itself, in pages/api/receipts/[id]/settle.ts β€” append the receipt id to the payout transaction's receiptIds on POST and remove it on DELETE (undo). Two things to confirm first rather than guess, because a wrong write path here corrupts transaction documents: 1. there is no existing helper that writes the transaction's root receiptIds (updateTransaction does not handle it; the vendor matcher writes it through applyVendorLink), so the correct write mechanism needs picking deliberately β€” reuse or extend, not a hand-rolled Firestore call; 2. whether transactionAdapter.toV1 actually maps receiptIds through. The adapter builds its output field-by-field with no passthrough, and a grep did not confirm the mapping β€” if it is absent the field will be written but invisible to the UI.

Deploy: owner, 2026-07-25 β€” "it's okay. We can deploy when the next phase completes". PR #932 stays a draft; no deploy fired.

2026-07-25 β€” PR #932 merged to main (not deployed)

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Merged as abbc678c9760495ada4170cf1235ec5689e757f2. Owner instruction, 2026-07-25: "Merge PR #932 without deploying." Production is unchanged β€” the WOPC restyle, pagination and the Reimbursements rework are on main but not live until a separate explicit deploy instruction.

Commit SHAs on this task (append-only, newest last): 3c4d57b measured restyle + pagination + purpose/closing forks Β· 18a5c61 restore line-item notes and short-table padding Β· 24a9f8f dynamic type scale + one-pager overflow fix Β· c9b8da3 single rule under the header row Β· 45164c4 Reimbursements tab rework Β· fbaac5d closing title from Individual.system.position Β· a6fb3d0 issue real reimbursement WOPCs Β· a9ad2b0 + 4295a05 + e07090d Firestore schema-approval rule Β· 6f40528 split T-180..T-183 Β· cc79d60 split phase 7 into T-184..T-186 Β· 4739519 T-187 bot handover Β· 4417104 decision index Β· f348b14 decision-index rule Β· merge abbc678.

T-178 stays doing β€” the payment pipeline still has open work: the gl migration for document links, the 2110 journal-ownership change (moving the settle entry from receipt-derived to transaction-derived, which is the highest-risk item because it is the double-post hazard this task has routed around throughout), receipt/transaction unmatch symmetry, showing the related transaction in the receipt detail drawer, and Records surfacing.

Deploy verification owed when it does ship: GL 2110 absent from the Match-Transaction dropdown; a contractor WOPC rendering in its real typefaces rather than Times New Roman; a 20-receipt reimbursement paginating 10/10 with the total and closing on the last page.

2026-07-27 β€” receiptIds moves under gl: schema APPROVED and built; backfill gated on deploy

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The gate was passed properly this time. The whole-document current-vs-after (with gl.receiptIds shown under its immediate parent, the reserved-key trap named, and the existing fields considered β€” gl.receipts, root receiptIds as-is, notes β€” each with the reason it doesn't fit) was put to the owner as its own question on 2026-07-27, and approved verbatim: "Moving receiptsId under gl? Was mutually consented. Thanks." This completes the direction the owner set on 2026-07-25 ("receiptIds should be written under gl?: as it's GL related, and also that I've tried to avoid writing information on a flat level") β€” the decision-index row from that date stands unchanged.

The approved shapes β€” recorded here in full so the migration audits from this file alone (path accounting/transactions/entries/{id}, database tebs-erl; legend: + added Β· βˆ’ removed Β· ~ changed Β· unmarked untouched):

CURRENT (whole document, production today):

{
  transaction: { status: 'unmatched'|'matched'|'partial'|'categorized',
                 transactional: { date, amount, isDebit, currency, bankAccountId, … },
                 merchant?, card?, fee?, netAmount?, counterpartyDirectoryId?, … }
  gl?:         { [accountCode]: GLAccountData | InvoiceAllocations,
                                          ← e.g. gl['5050'] = { payeeId, relatedProjectId, wopcRef, expenseMetadata?, attachments? }
                                          ← e.g. gl['4000'] = { "2025-001-0103": [{ amount }] }
                 coaching?:         CoachingAllocations,          ← reserved, journal-inert
                 coachingInvoices?: CoachingInvoiceAllocations,   ← reserved, journal-inert
                 receipts?:         ReceiptAllocations }          ← reserved, journal-inert
  notes?:      string
  receiptIds?: string[]                   ← flat root: "documents attached to this transaction"
}

AFTER (approved 2026-07-27):

{
  transaction: { … }                      unchanged
  gl?:         { [accountCode]: …,        unchanged
                 coaching?: …,            unchanged
                 coachingInvoices?: …,    unchanged
                 receipts?: …,            unchanged
+                receiptIds?: string[] }  ← MOVED here β€” under its parent `gl`, sibling of the
                                            other reserved keys; joins GL_RESERVED_KEYS in the
                                            same commit so getAccountCodeFromGL never reads it
                                            as an account code
  notes?:      string                     unchanged
βˆ’  receiptIds?: string[]                  ← removed after the one-time backfill (typed LEGACY,
                                            read-fallback only, until then)
}

Existing fields considered and rejected (put to the owner with the proposal): gl.receipts (ReceiptAllocations β€” means "this tx IS the expense, evidenced by these receipts"; the display resolver keys the receipt template off it, so reuse would mislabel vendor links) Β· root receiptIds as-is (works, but is the flat-root pattern the owner directed away from) Β· notes (unstructured).

What was built:

  • lib/accounting/types.ts β€” GLAssignment.receiptIds?: string[] (journal-inert document links); 'receiptIds' joins GL_RESERVED_KEYS in the same change β€” the trap this migration had to respect is that accountCode is derived from the gl map's keys, so an unreserved receiptIds key would read as an account code named "receiptIds". The root TransactionDocument.receiptIds stays typed but is marked LEGACY: read-fallback only, removed after the backfill.
  • lib/accounting/receipts.ts β€” the two private helpers every root write funnelled through (addReceiptIdToTransaction / removeReceiptIdFromTransaction, which also serve the vendor matcher via matchReceiptToTransaction) now read the union of both locations and write only gl.receiptIds, deleting the legacy root field in the same update β€” every transaction the live paths touch is self-migrated into the final shape.
  • lib/accounting/transactionAdapter.ts β€” toV1 reads gl.receiptIds ?? receiptIds (fallback marked TEMPORARY); prepareForWrite keeps a legacy-tolerance passthrough so a pre-backfill document round-tripped through it cannot lose links. The V1/UI shape is unchanged β€” v1.receiptIds stays where every component expects it, so no UI edits were needed.
  • scripts/backfill-receiptids-under-gl.ts β€” one-time sweep: per doc, gl.receiptIds ← union(gl, root) + root deleted, one update per doc so links always live somewhere. Dry-run by default, --apply to write, idempotent.

⚠️ RUN ORDER IS LOAD-BEARING β€” the runbook: 1. Merge PR #933. Do not run the backfill yet. 2. Deploy (owner's explicit command, per policy). The deployed build must carry the new GL_RESERVED_KEYS β€” running the backfill under the OLD build would make its getAccountCodeFromGL read gl.receiptIds as an account code on every migrated transaction, and the journal generators would act on it. This is the same class of hazard the 2110 exclusion guards against. 3. npx tsx scripts/backfill-receiptids-under-gl.ts (dry-run, review) β†’ re-run with --apply. 4. Follow-up PR: remove the adapter's root fallback, the prepareForWrite passthrough, and the legacy type field. No permanent dual-read β€” the fallback exists only between deploy and backfill.

Between deploy and backfill, a document neither touched nor yet swept still renders correctly through the fallback; writes are single-shape (gl only) from the moment the new build serves β€” the owner's "no different versions of how data is written" holds at the write layer throughout.

Verified: unfiltered tsc --noEmit --incremental false clean Β· vitest run 738 passed (same 4 known pre-existing) Β· eslint error count identical to a stashed clean HEAD.

Blast radius: every reader of transaction document-links goes through toV1, which is updated β€” UI untouched. The T-178 settle-marker write (payout tx ↔ reimbursed receipts) now has its final home and lands directly in gl.receiptIds when built. T-186's vendor-invoice matching inherits the new location through the shared helpers.

2026-07-28 β€” PR #933 merged (not deployed); backfill armed and waiting on the deploy

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Merged as f2d310a35473074a3fb38f6b18736824e72a31a8 on the owner's "Proceed" with the recorded run order. The pre-merge gate ran in full for the first time in a while: fresh npm ci (which required reconciling the lockfile drift open since step 1a β€” 584a293; the Telegram agent had independently fixed it on main at 52889f5, and the merged lockfile was re-verified with another clean-room install) + unfiltered tsc + vitest 750 passed (4 known pre-existing) + a Codex review round with all four findings confirmed and fixed (cbe41f5).

Merging required absorbing a moved main: the Telegram agent's T-187 close-out and the User Management agent's T-188 landed first. Resolutions: my postage task renumbered T-188 β†’ T-189 (collision rule: merged second, renumbers); ProfileApp keeps both their name composer and T-183's PaymentDetailsCard; and their verified finding β€” reference numbers are ERL-WOPC/{YYYY}-{NNN} since #664, no abbreviation β€” is absorbed as a correction on T-183 and the decision index.

Production is UNCHANGED. The remaining run order, in the owner's hands: 1. Deploy β€” owner's standalone command only. 2. npx tsx scripts/backfill-receiptids-under-gl.ts (dry-run, review) β†’ --apply. NEVER before the deploy β€” the old build reads gl.receiptIds as an account code. 3. Follow-up PR removes the adapter fallback + legacy type field.

Commit SHAs on this task (append-only, newest last) β€” adding this PR's: 34432c0 board note Β· 5127b49 gl.receiptIds migration + backfill script Β· 584a293 lockfile reconcile Β· 7c32ef4 merge of main (T-188β†’T-189 renumber) Β· cbe41f5 Codex round (adapter union; approved shapes recorded) Β· merge f2d310a.

Deploy verification owed when it ships (accumulated): GL 2110 absent from the Match-Transaction dropdown Β· a contractor WOPC in real typefaces (not Times New Roman) Β· a 20-receipt reimbursement paginating 10/10 Β· the Receipts / Service Invoices tabs standing separately with per-tab upload Β· "Can't be paid yet" on an incomplete employee Β· a post-office receipt classifying as Postage β†’ 6300 Β· notification deep links opening the document drawer.

2026-07-28 β€” DEPLOYED Β· backfill applied and verified Β· migration complete

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The whole run order executed on the owner's standalone "deploy" (2026-07-28):

  1. Deploy β€” hook fired (job 9J689jN47AnEEf3dvU7a), production deployment dpl_FysF29VhRedunTWtTPZUTzEpbrvD READY on commit 64977fd β€” that is #941 (the Telegram agent's I-037 CJK prompt fix) merged directly on top of our f2d310a, verified as its descendant, so everything from #933 is live: the WOPC restyle + pagination, the Reimbursements rework, payment readiness, the split tabs, postage, and the gl.receiptIds build.
  2. Backfill (scripts/backfill-receiptids-under-gl.ts, run only after READY): dry-run showed 184 transactions scanned Β· 10 carrying a root receiptIds β€” of which only 2 held real links (8DX0qDUm5UuePIMVaPoK β†’ nnTHOFq6acmdpI2S6q7l, EgxavxxoBveNmSdF78Cx β†’ yl2tpD8SKIvqM0mfOQ2L) and 8 were vestigial empty arrays left by old unmatch flows. The script gained an empty-root branch first (delete the root, write nothing β€” migrating [] would have minted empty gl keys), then --apply ran: 2 migrated, 8 cleaned. Verification dry-run after: 0 left to migrate, 2 already in gl, 182 with no links. Production is fully in the approved shape.
  3. Fallback removal (the contract step, in this commit): toV1 reads gl.receiptIds only; prepareForWrite's root passthrough removed; the LEGACY TransactionDocument.receiptIds type field deleted (the adapter test fixture moved with it). unionReceiptIds keeps a defensive raw-doc read but nothing writes a root field any more. Script kept β€” idempotent, documented, and its dry-run doubles as the drift check. Also fixed on the way: the script's __dirname breaks under ESM loaders (ts-node-transpile-only is what actually runs here; npx tsx isn't installed) β€” now resolves .env.local from cwd.

Gates: unfiltered tsc --noEmit --incremental false clean Β· vitest run 750 passed (same 4 known pre-existing) Β· eslint identical to clean HEAD (9 = 9, all pre-existing lines).

The receiptIds migration is COMPLETE β€” one shape in production, one shape in code, no dual-read. Still open on T-178: the 2110 journal-ownership change (its settle-marker write now has its final home at gl.receiptIds), receipt/transaction unmatch symmetry, the receipt-drawer transaction panel, and Records surfacing.

Owner UI verification now possible (accumulated checklist): GL 2110 absent from the Match-Transaction dropdown Β· a contractor WOPC in its real typefaces Β· a 20-receipt reimbursement paginating 10/10 Β· Receipts / Service Invoices as separate tabs with per-tab upload Β· "Can't be paid yet" on an incomplete employee Β· a post-office receipt classifying as Postage β†’ 6300 Β· a notification deep link opening the document drawer.

SHA list additions (append-only): the fallback-removal commit in PR #940 + this board commit.

2026-07-28 β€” The 2110 journal-ownership change: built

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

What the app does differently now, in plain terms. When a bookkeeper settles an employee's reimbursement and picks the bank payment that repaid them, the app now marks that bank transaction itself as a reimbursement payout β€” it gets GL 2110 assigned and remembers which receipts it paid (in its gl.receiptIds list). Three things follow:

  1. The payout stops looking "unmatched" in the ledger. Before, settlement was recorded only on the receipt, so the bank transaction that paid the employee sat in the transactions list looking like nobody had dealt with it β€” forever. Now it reads as categorized to 2110, like any other handled transaction.
  2. The books get the bank side right. The accounting entry that clears the employee payable ("debit 2110, credit the bank") is now produced from the transaction, which knows which real bank account the money left. Before, it was produced from the receipt β€” and a dormant bug meant the receipt's lookup of the bank account read fields that don't exist where it looked, so every linked settle silently credited the default bank (1000) instead of the real one. Found and fixed on the way.
  3. Un-doing is symmetric. If a bookkeeper un-assigns GL 2110 from a payout transaction, the app now also reopens the receipts that payout had settled β€” they reappear as unpaid on the Reimbursements tab, ready to be settled against the right payment. Before, nothing connected the two: the books would lose the payment record while the receipts still claimed "paid".

Why nothing double-posts β€” the rule in one sentence. Every settle has exactly ONE owner: if the receipt names a payout transaction that carries GL 2110, the transaction produces the accounting entry; otherwise (no transaction picked, or an old settle whose transaction hasn't been flipped yet) the receipt produces it β€” the two conditions cannot both be true, so the entry appears exactly once in every possible data state, including mid-migration.

GL 2110 returns to the Match-Transaction dropdown. The Phase-2 exclusion existed because a direct 2110 match would have double-posted against the receipt-produced entry. Under transaction ownership a direct match IS the entry, so the ban lifts. (Decision-index row from 2026-07-24 marked superseded.)

Settling against a transaction that already means something else is refused. If the picked transaction is already categorized to another GL, the settle returns an honest error instead of fighting the books β€” pick a different payout, or un-assign that transaction first.

Firestore documents touched β€” structures, per the gate: - accounting/transactions/entries/{id} (tebs-erl): no structure change. Settling writes gl['2110'] = {} (a GL assignment in the existing shape, the same one the Match modal writes) and appends to gl.receiptIds (the field approved 2026-07-27). Un-assigning 2110 now also deletes gl.receiptIds. - accounting/receipts/entries/{id} (tebs-erl): no structure change. The same three settle fields (metadata.settledAt/settledBy/settledBankTxId) are written and cleared by the same single writer as before; the un-assign path clears them through that writer too.

Code pointers (after the plain story, per the new reporting rule): settle route pages/api/receipts/[id]/settle.ts (assigns via the shared updateTransaction writer + addReceiptIdToTransaction); ownership condition + fixed bank lookup in generateReimbursementEntriesServer (derivedJournals.server.ts); symmetry in uncategorizeTransaction + the unassign-account API branch; dropdown in TransactionLinkingModal.tsx; flip script scripts/backfill-2110-settle-ownership.ts.

⚠️ Run order for the flip script β€” same rule as the receiptIds backfill: merge β†’ deploy β†’ dry-run β†’ --apply. Running it before the deploy would make the OLD build (receipt-derived settles + generic categorized entries) emit both sides at once in live reports. The script skips-and-reports conflicts (transaction already categorized elsewhere) and missing transactions (free-typed legacy ids) β€” both stay receipt-owned, which remains correct indefinitely.

Verified: unfiltered tsc --noEmit --incremental false clean Β· vitest run 750 passed (same 4 known pre-existing) Β· eslint identical to clean HEAD (89 = 89; the new script adds zero). The exactly-once property holds by construction (mutually exclusive ownership condition); runtime verification after the next deploy: settle a receipt with a picked payout β†’ the payout shows categorized 2110 β†’ the journal shows ONE clearing entry with the real bank account β†’ un-assign the payout β†’ the receipt reopens.

T-178 remaining after this: receipt-drawer transaction panel + Records surfacing.

2026-07-28 β€” Owner officializes: director reimbursements go through the signing pipeline; sign GATES settlement

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The hole this closes. The reimbursement issue flow was built on the assumption "an employee is not a related party, so the document closes unsigned" β€” at the owner's earlier request, believing director reimbursements needed no signature. The owner has corrected that premise: the signing convention exists because most WOPCs are paid TO a director, and a second director's sign-off is the deliberate "looks legitimate" control. That logic keys on WHO RECEIVES THE MONEY, not on the document type β€” so a reimbursement to a director must not bypass it.

Owner, verbatim (2026-07-28):

"First of all, it's a variant of a WOPC that we're discussing. Since the creation of the WOPC, because the web app has only been used by Jeff (especially the WOPC issuing and tx making), a lot of the pipeline (WOPC especially) has been built to be signed off by another director when it's made for one director. But I have since requested for a separate pipeline because I falsely believe that WOPC for issuing reimbursement for a director doesn't require any signature, so that this signing by the other director pipeline can be bypassed. While I intend to keep this no-signature-required pipeline, WOPC for issuing reimbursement should still goes thru the same signing by another director pipeline when it's issuing to one director.

So TL;DR, there will be 2 types of WOPC β€” one for issuing sub-contractor fee (existing) and one for reimbursement; there will be 2 processing procedure β€” when issuing to one director, the web app should allow the operating user to request for another director's signature (existing), or it should be issued without anybody's signature if it's not issued to a director.

Sign gates the settlement."

The model, restated: document TYPE (fee / reimbursement) decides the content; the PAYEE decides the procedure. Director payee β†’ the operator can request the other director's signature through the existing pipeline (operator-initiated, per "should allow the operating user to request"); non-director payee β†’ issued unsigned, and that pipeline is deliberately kept. Timing: the signature gates the settlement β€” for a director's reimbursement, receipts cannot be marked settled (and the payout cannot be claimed in the books) until the co-director signs. Chosen over the alternative ("sign legitimizes the document, settle immediately") that was put to the owner with the consequence stated: a payout that already left the bank will read as unhandled until the signature lands. Accepted.

Build sketch for the next phase (not yet built): the issue step detects a director payee (the existing related-party facet) → sets the OTHER director as signer (the cross-issuance rule that already prevents self-signing applies unchanged) → document closes in the signed style and enters the existing request-signature flow → the Reimbursements-tab confirm does NOT settle those receipts; they settle when the signature lands (hooking the existing sign-then-release machinery, which fee WOPCs already use to hold payments). Non-director flow is exactly what ships today. Known prerequisite: the WOPC→receipt link exists (line items), but the receipt→WOPC backlink is the noted follow-up — the sign-completion hook needs to know which receipts to settle.

2026-07-28 β€” Director-reimbursement signing fork: built (the no-schema half)

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Owner confirmed sign-gating is an existing feature and said to dive in. What the app now does:

When the bookkeeper reimburses a DIRECTOR, the confirmation screen changes shape: the bank-payment picker disappears (settlement comes later), an explanation appears β€” "{director} is a director, so this Payment Confirmation must be signed by {the other director} before the receipts can be settled" β€” the document preview shows the signed closing with the other director's name, and the button reads "Issue & hold for signature". Confirming issues the document with the other director assigned as signer (the existing cross-issuance rule β€” a director can never sign their own payment β€” applies unchanged) and settles nothing: the receipts stay open, and the operator requests the signature through the existing Records β†’ WOPCs actions. Non-director reimbursements are untouched: unsigned closing, payout picker, immediate settlement β€” the deliberately-kept pipeline.

Firestore: no structure change in this half. The issue step writes WOPC.closingDirectorId β€” the same existing field, in the same shape, that fee documents have always used to enter the signing pipeline. Receipts are not written at all in the director flow (that is the point β€” they wait).

What is deliberately NOT yet built — blocked on two schema questions put to the owner: 1. Enforcement teeth: nothing yet STOPS a bookkeeper manually settling a receipt whose document awaits signature — the settle route cannot know the receipt belongs to a held document, because the receipt does not name its document. Needs the receipt→document backlink (proposed: metadata.wopcRef). 2. Settle-on-sign: when the signature lands, the app should settle the document's receipts — but the signing pipeline cannot tell a reimbursement document from a fee document (they are distinguished only by content, as this task's own schema entry predicted would one day need a real field). Needs WOPC.purpose. Both presented whole-document per the gate; building continues on approval.

Code pointers (after the story): pages/api/accounting/reimbursements/issue.ts (director detection + cross-issuance signer), components/accounting/ReimbursementsTab.tsx (the fork), directors resolved via the existing registry (getDirectorByLoginEmail / getOtherDirector).

Verified: unfiltered tsc clean Β· vitest 750 passed (same 4 known pre-existing) Β· eslint clean on both changed files.

2026-07-28 β€” Receipt immutability: processed receipts are now undeletable; held-WOPC lock + regrouping proposed

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Owner, verbatim (2026-07-28): "1. Make it so that a receipt that's attached to a WOPC awaiting signature cannot be edited or deleted 2. Make sure that a receipt that's been processed can never be deleted 3. Take the chance to group relevant fields on a receipt firestore doc under map fields. Propose to me how you'd group them"

Item 2 β€” BUILT. Deleting a receipt now fails with a clear message once the receipt has been processed β€” defined as: its submitter confirmed it (reviewState === 'confirmed'). A confirmed reimbursement has already posted its DR expense / CR 2110 entry, and a confirmed company-card receipt is the bookkeeping evidence for its transaction β€” deleting either erases part of the books. The guard lives at the single delete chokepoint (deleteTelegramReceipt), beside the existing already-reimbursed refusal, so every delete path β€” web drawer, batch actions, hand-typed API call β€” hits it. The re-run loophole is closed: an AI re-run temporarily demotes a confirmed receipt to pending_user_confirm, which would have slipped past a naive state check; the guard also consults preRerun.reviewStateBefore, so a mid-re-run receipt that WAS confirmed still refuses. Deliberately NOT covered: reviewState === 'edited' (user-corrected but never confirmed β€” has not entered the books; still deletable). Firestore: no structure change β€” reads existing fields.

Item 1 — blocked on the receipt→document backlink (the wopcRef schema question, now folded into the item-3 regrouping proposal): the edit/delete lock for receipts on a signature-held WOPC cannot be enforced until a receipt can name its document. Enforcement lands with the approved shape: while the named WOPC awaits signature → field edits, category/GL changes, AI re-runs and deletion all refuse.

Item 3 — proposal presented to the owner in chat (whole-document before/after): metadata's twelve loose scalars group into review / accounting / settlement / matching / wopc maps; the five existing maps (parsedReceipt, originalParsedReceipt, preRerun, drive, telegram) stay as they are; the ROOT of the document is deliberately out of scope — it is the shared shape of the whole document store (vendor invoices, archived WOPCs), so regrouping it would drag every document type into the migration. Awaiting approval; migration would follow the same expand→backfill→contract discipline as gl.receiptIds.

Verified: unfiltered tsc clean Β· vitest 750 passed (same 4 known) Β· eslint 2 = 2 vs clean HEAD (both pre-existing).

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Owner approvals executed this entry: the metadata regrouping ("Item 3 confirmed. Proceed") and the amended lock rule ("for pending and processed receipt, disallow all editing, AI re-run and deletion by hiding related buttons" β€” "pending" read as pending-SIGNATURE, stated to the owner for correction).

What the app does differently now, in plain terms: 1. A confirmed receipt is frozen. Once its submitter confirms it, nobody can edit its fields, change its category or accounts, re-run the AI on it, or delete it β€” the buttons are gone from the receipt drawer (one lock hides every pencil, all three re-run buttons, Confirm and Delete at once), and the server refuses the same operations for anyone bypassing the UI. A note in the drawer footer says why, and names the Payment Confirmation the receipt sits on when there is one. 2. "Sign gates the settlement" now has teeth. Issuing a Payment Confirmation stamps each receipt with the document's reference (the approved backlink). Settling a receipt whose document has a signing director but no signature is refused with the reference named; on the Reimbursements tab such receipts show an "Awaiting signature" tag and their checkboxes are disabled, so a second payout run cannot even pick them. Voiding or deleting the document clears the backlinks so no receipt stays locked behind a dead document. 3. The receipt record is reorganised as approved. Twelve loose fields became five labelled groups β€” review / accounting / settlement / matching / wopc β€” with the five existing maps and the document ROOT untouched (the root is the shared shape of the whole document store).

Firestore β€” the approved regroup, now written by every live path: the store translates the same caller inputs onto the grouped layout and deletes the superseded flat key in the same write (self-migrating on touch); ONE exported translation (normalizeStoredReceiptMetadata) serves every raw-document reader (reimbursements feed, matchable feed, journal generators) so there is exactly one place that understands both shapes β€” TEMPORARY, removed after the sweep. All 16 reader/writer files migrated; a grep sweep confirms zero flat-path reads remain; test fixtures moved to the grouped shape.

⚠️ Run order for scripts/backfill-receipt-metadata-groups.ts β€” REVERSED hazard vs the gl one: merge β†’ deploy β†’ dry-run β†’ --apply. Running it before the deploy would make every migrated receipt INVISIBLE to the live build (its readers look for the flat keys the script deletes) β€” the Reimbursements tab, journals and matching would all lose those receipts until the deploy. The script unions audit trails rather than picking, lets nested values win (live writers self-migrated those), covers both receipt databases, and is idempotent.

Code pointers (after the story): store + normalizer lib/telegram/receiptStore.ts Β· locks pages/api/receipts/[id].ts (PATCH), [id]/rerun.ts, deleteTelegramReceipt Β· sign-gate [id]/settle.ts + backlink write in reimbursements/issue.ts + cascade in lib/wopc.server.ts Β· drawer lock context ReceiptDetailDrawer.tsx Β· tab gating ReimbursementsTab.tsx + feed reimbursements.ts.

Verified: unfiltered tsc clean Β· vitest 750 passed (same 4 known pre-existing) Β· eslint 29 = 29 vs stashed clean HEAD across all 19 touched files (script adds zero) Β· flat-read grep: zero.

Still open on T-178: WOPC.purpose (schema question still awaiting the owner) β†’ settle-on-sign automation Β· receipt-drawer transaction panel Β· Records surfacing.

2026-07-28 β€” Settle-on-sign built: the signature settles the receipts and releases the payment

βœ… Read AGENTS.md Β· checked the board by scope (no dup) Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Owner, verbatim (2026-07-28): "Receipts should settle automatically after or at the moment of the co-director signing the WOPC, and the payment transaction being submitted simultaneously" β€” and confirmed "pending" in the lock rule means pending-SIGNATURE.

What happens at the moment the co-director signs, in order: 1. The held payment fires β€” this already worked: the signature-release queue (built for fee documents) keys only on the document reference, so a payment queued behind a reimbursement document releases at the same signature with no change needed. 2. The receipts settle themselves β€” new: the sign-completion hook finds every receipt naming this document (the backlink written at issue) and settles each unsettled one through the SAME settle core the manual endpoint uses β€” extracted into one shared function so there is exactly one settle dialect. If the document already has a linked payout transaction, the settle carries it; otherwise the entries stay receipt-owned until the payment arrives. 3. When the payment syncs back from the bank, the auto-matcher now recognises a reimbursement document β€” by its backlinked receipts, not by a stored type field β€” assigns the payout GL 2110 instead of contractor fees, records every receipt on the transaction, and fills in each settled receipt's missing payout pointer. The exactly-once ownership condition flips the journal entry to the transaction automatically.

The WOPC.purpose schema question is WITHDRAWN. The backlink turned out to be the discriminator: a document with receipts naming it IS a reimbursement document, and fee documents have none. No new field, no approval needed β€” the pending question dies unanswered because it stopped being necessary.

What still needs the operator: QUEUING the payment at issue time. The queue's authorization chain (deliberate, owner-directed in its own design) requires the creator to review the exact transfer β€” amount, saved beneficiary, source account β€” before it is held behind the signature. The Reimbursements confirm does not collect those Airwallex particulars; the Pay-with flow does. Routing reimbursement documents into that flow is exactly T-184's scope ("Pay with…" on Bank Access), noted there. Until then: issue β†’ request signature β†’ sign settles the receipts; the payment is made through the existing channels and links up via auto-match.

Firestore: no structure change. The hook reads the backlink, settles through the existing single writer, and the auto-match branch writes GL assignments and document links in their existing shapes.

Code pointers: shared settle core lib/accounting/reimbursementSettle.server.ts (endpoint delegates); sign hook in recordSignature (lib/wopc/signingRequests/operations.server.ts, beside the payment release, same never-unwinds-the-signature pattern); auto-match branch in lib/accounting/wopcAutoMatch.server.ts (dynamic imports keep the storage bucket out of unit-test import chains β€” a suite stopped collecting until that was fixed).

Verified: unfiltered tsc clean Β· vitest 750 passed (same 4 known pre-existing; the auto-match suite gained a mock for the new discriminator probe β€” deliberately NOT fail-soft in production, since a Firestore blip silently booking a reimbursement as contractor fees would corrupt the books) Β· eslint 7 = 7 vs clean HEAD.

T-178 remaining: receipt-drawer transaction panel Β· Records surfacing Β· (payment-queue-at-issue lives in T-184).

2026-07-28 β€” OPEN: owner challenge on the auto-match branch (consultation, unresolved)

βœ… Read AGENTS.md Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The owner challenged the settle-on-sign wrap-up on two grounds, verbatim (2026-07-28): "you have modified the way the web app reads and writes the Firestore doc, and that you didn't consult with me on it" β€” and whether "the GL matching should be done manually after a tx has been made and are stored and shown on the Accounting page, or the web app passes information to an auto-matched as soon as a WOPC is signed, and let the auto-matcher does its magic instead".

The contested piece is the auto-match reimbursement branch (auto-assigning GL 2110 to the synced payout, recording the receipts on the transaction, filling settled receipts' payout pointer): its write-set was designed and built without prior consultation. It exists only on the unmerged PR (#940) β€” nothing unconsulted is live. Two questions are open with the owner:

  1. Whether the payout's GL matching stays automatic at sync (as built, mirroring fee documents since T-168), becomes manual on the Accounting page, or becomes propose-only (the matcher finds the payout and raises a match_suggestion; the owner confirms; only then anything is written).
  2. Whether AGENTS.md gains a read/write behaviour consultation gate alongside the existing structure gate.

Until both resolve: no further Firestore read/write behaviour changes on this pipeline. The decision row "auto-match assigns GL 2110" on [[decisions]] is marked under review; if the owner picks manual or propose-only, the branch changes before merge and the row is rewritten.

Resolved 2026-07-28 β€” owner: "2=A" β€” GL matching stays automatic at sync, as built. Mirrors what fee documents have done since T-168; the amount guard, the loud reimbursement probe and the unassign reset stay as the safety net. The decision row's "under review" flag is cleared for the automatic-vs-manual question; the AGENTS.md gate question stays open at the owner's direction ("1=Let's get things clarify first before settling it").

2026-07-28 β€” STRUCTURE REVIEW (open): what a reimbursement payout writes on the TRANSACTION

βœ… Read AGENTS.md Β· tracking T-178. Structure gate β€” presented, not built pending approval. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Owner, verbatim: "I don't think we've ever discussed the current shape of the tx firestore doc when matching a tx to 2110 (what's gonna be written), so let's discuss it." Correct β€” it was never presented. Live production today writes nothing at all on the transaction when a receipt settles (only the receipt's own settlement fields change), and auto-match always assigns contractor fees. Everything below exists only on the unmerged PR #940.

Transaction document β€” accounting/transactions/entries/{txId}

Current (live) β€” a reimbursement payout that has been settled against:

transaction:
  transactional: { amount, currency, transactionDate, description, referenceNumber, … }
  status: "unmatched"          ← never advances; nothing links it to the receipts
  merchant / card / …
(no gl map at all)

After (as built on #940) β€” the same payout once settled / auto-matched:

transaction:
  transactional: { … }                                    ← untouched, bank-owned
  status: "categorized"                                    ← was "unmatched"
  merchant / card / …                                      ← untouched
gl:
  "2110": { payee, payeeAbbreviation, wopcRef, memo? }     ← same map shape fee payouts write
                                                              under "5050"; only the key differs
  receiptIds: ["<receiptId>", …]                           ← THE CONTESTED ADDITION
updatedAt / updatedBy: "system:wopc-auto-match"            ← existing audit fields
updateLog/  (subcollection)                                ← existing audit trail

Only ids travel. No merchant, amount, date, category or note is copied from a receipt onto the transaction β€” gl.receiptIds is a list of document ids and nothing else.

gl.receiptIds is not new: it is the existing "documents attached to this transaction" list (a vendor invoice attached to its payment writes the same field), moved under gl by the owner-approved 2026-07-27 change. It is journal-inert β€” getAccountCodeFromGL skips it, so it can never be mistaken for a GL code. What is new is reimbursement receipts joining that list.

Why anything is written there at all: it is the reverse lookup. Unassigning GL 2110 must reopen the receipts that payout settled (else the books lose the settle leg while the receipts still say "paid"); the cascade reads gl.receiptIds to know which ones. The books themselves do not read it β€” journal ownership is decided by the receipt's settlement.bankTxId plus the transaction's GL code.

Options put to the owner:

  1. As built β€” gl.receiptIds carries them. Reuses the established attachment field and its existing writer; the payment visibly lists the receipts it repaid; cheap reverse lookup. Cost: the same fact is stored on both ends (receipt β†’ tx, tx β†’ receipt), so a half-failed write can leave them disagreeing, and one array now holds two different relationships (evidence-of-charge for a vendor invoice, repaid-by-this-payment for a receipt), told apart only by the GL code.
  2. Nothing on the transaction β€” the link lives only on the receipt (settlement.bankTxId); the unassign cascade finds its receipts by query instead of by stored array. The transaction's only change becomes its GL code. Cost: one query where there is now an array read; a payment cannot list its receipts without looking them up. Benefit: matches the owner's own 2026-07-25 rule β€” "Receipt and transaction each answer their own question … no duplicated fact, so nothing can disagree".
  3. Its own field (e.g. gl.settledReceiptIds) β€” keeps "paperwork for this charge" and "receipts this payment repaid" visibly distinct. Cost: a genuinely new field, and the duplication of option 1 remains.

Recommendation: option 2, on the owner's own no-duplicated-fact principle β€” the reverse lookup is one query, and it is the only consumer. Option 1 is defensible only if seeing the receipts listed on the payment is worth carrying a second copy of the link.

Agent's own question: should a payment that repaid several receipts say so on itself at all, or should the transaction stay silent and the receipts remain the only place that records which payment repaid them?

RESOLVED 2026-07-28 β€” owner: "option 2". The transaction stays silent. The link lives only on the receipt (settlement.bankTxId); the transaction owns only its GL code (2110). Built on #940:

  • The settle core (reimbursementSettle.server.ts) no longer writes a reverse pointer β€” it assigns the payout GL 2110 (the tx's own fact) and stamps the receipt's settlement block; nothing else.
  • The auto-matcher (wopcAutoMatch.server.ts) still assigns GL 2110 to the synced payout and still fills a sign-time-settled receipt's missing settlement.bankTxId (the receipt owning its own link), but no longer records the receipts on the transaction.
  • The settle DELETE clears the receipt's settlement fields and stops there β€” there is no tx-side link to unwind.
  • The payout unassign (transactions/[id].ts) finds the receipts to reopen by querying metadata.settlement.bankTxId == txId (new reader findReceiptIdsSettledByTransaction) instead of reading a stored gl.receiptIds array.
  • The ownership backfill (backfill-2110-settle-ownership.ts) flips only gl['2110']; it writes no gl.receiptIds.

The exactly-once settle-leg ownership is unchanged and never depended on the reverse pointer: it is decided by the receipt's settlement.bankTxId and the transaction's GL code (derivedJournals txsOwning2110), which are mutually exclusive in every data state.

Firestore, transaction document β€” after option 2: a reimbursement payout carries transaction.* (bank-owned, untouched), transaction.status: "categorized", and gl['2110']: {} β€” and nothing about receipts. This matches the owner's 2026-07-25 rule (receipt and transaction each answer their own question, no duplicated fact) and mirrors how a 5050 fee payout stores only a wopcRef pointer, never the WOPC's line items (the finding that opened T-190).

Verified: unfiltered tsc clean Β· vitest 750 passed (same 4 known pre-existing billing-ingest) Β· eslint 9 = 9 vs stashed clean HEAD across the six touched files.

2026-07-28 β€” Cross-seam: T-192 contract accepted; my duplicate T-190 retired

βœ… Read AGENTS.md Β· read T-191 + T-192 (taskboard branch) + the recent main commits Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

The Accounting (Diagnostics) agent (owner-relayed) sent the T-178 contract, and reading their board surfaced two things:

  1. My T-190 was a duplicate of their T-191 and a UID collision. I independently created "a transaction stores references, not copies" as T-190 β€” reaching the same findings (stale copies, lossy multi-project). But T-190 was already taken (User Management's "approvals route to a rank", merged #943), and the references-not-copies work already lives in T-191 (Accounting Diagnostics), far ahead of mine β€” they have already flipped 5050 enrichment to WOPC-first and made it deterministic. My T-190.md is deleted; the decisions row repointed to T-191.

  2. T-192 is assigned to me (Records Infrastructure) and states a binding contract for T-178's new 2110 write path: a 2110 transaction's gl['2110'] node must store only wopcRef** β€” no denormalised payee / contractor / address / bank / project copies. If 2110 honours it, T-191's GL-agnostic WOPC-first enrichment covers 2110 rows for free.

Where my current build stands against that contract (honest gap): - The reimbursement auto-match (wopcAutoMatch.server.ts) currently writes { payee, payeeAbbreviation, wopcRef, memo } onto gl['2110'] via assign-account β€” it denormalises, violating T-192. To fix: write only { wopcRef }. - Latent bug found while checking this: the reimbursement WOPC is created at issue as pending_transaction, but the shared matcher's Step 2 (save-payment-confirmation β†’ createWOPC) always mints a new reference and doc. So the payout auto-match likely creates a duplicate WOPC instead of linking the pre-created one. The correct 2110 path must link the existing WOPC (status pending_transaction β†’ active + tx), not create a second one. (Needs a confirm pass β€” the fee create-pending flow shares this matcher and appears to work, so there may be handling I have not yet located.)

Why this is not done this turn (deliberate): - T-191's shared WOPC-resolution helper + GL-agnostic enrichment are not merged to main yet (only the old WOPC.refNumber reads are on main). The handover says to reuse the helper rather than copy fields β€” so the read side waits for it. - Stripping the 2110 copies before that enrichment is live would blank 2110 display rows β€” acceptable only because no 2110 transactions exist yet (T-192 confirms) and T-178 is unmerged, so there is no regression, but the merge order matters: T-191 live before/with T-178's 2110 path going live. - The real fix (link-don't-duplicate + store-only-wopcRef) is a designed change that belongs in a scoped T-192 pass with the owner's go, not a slipped-in edit.

T-192 status: contract accepted and recorded; implementation pending (a) T-191's resolver landing on main and (b) the owner's go on sequencing. No 2110 write-path code changed this turn.

Sequencing decided (owner, 2026-07-28): wait for T-191, then one pass. T-192 is deliberately queued behind T-191's merge to main β€” I will implement the 2110 path (link the existing WOPC instead of duplicating it + store only wopcRef, reusing T-191's published WOPC-resolver) in a single coherent pass once the resolver is on main, so the reimbursement display is live immediately. Until then no 2110 write-path change lands. Trigger: T-191 merged to main β†’ begin T-192.

2026-07-28 β€” #940 marked ready: Codex review round + the 2110 payout guard

βœ… Read AGENTS.md Β· tracking T-178. The owner marked #940 ready for review; Codex auto-reviewed and raised four findings, all confirmed against the code and fixed:

  • P1 β€” the sign gate was inert. settle.ts and reimbursements.ts read the WOPC's director/ signature/void state from FLAT fields (wopc.closingDirectorId / signedAt / voidedAt), but the stored document is bucketed (WOPC.closingDirectorId, WOPC.signature.at, WOPC.void.at) and getWOPCByReferenceNumber's as WOPCDocument cast hid the mismatch from tsc β€” so the condition was permanently false and a receipt on an unsigned held document was settleable / selectable. Both now read through the canonical toView() accessor. (This is the sign-gate feature; it was shipping switched off.)
  • P1 β€” a partially-regrouped receipt dropped out of every filter. patchParsedReceipt stamps review.edited on every write but review.state only when a new state is passed, so a wopcRef backlink on a legacy receipt created a review map with no state; the normalizer then ignored the legacy flat reviewState, and the confirmed receipt read as stateless. The normalizer now resolves each nested field independently with a per-field legacy fallback. Guarded by a new unit test.
  • P2 β€” the 2110 ownership backfill would have flipped nothing. It read the flat receipt fields the metadata-groups backfill (run first) deletes; now reads grouped-first with a flat fallback.
  • P2 β€” four rerun/revert writers wrote the flat reviewState the normalized reads no longer see; moved to metadata.review.state + the grouped audit path.

The 2110 payout guard (owner: "guard it now, then merge"). Investigating the duplicate-WOPC risk confirmed it: the reimbursement WOPC is pre-created at issue, but the shared auto-matcher's Step 2 (save-payment-confirmation β†’ createWOPC) always mints a NEW reference, so a reimbursement payout would spawn an orphaned duplicate. Until T-192 rebuilds the path (link-don't-duplicate + store only wopcRef), the auto-matcher now detects a reimbursement payout, raises a match_suggestion, and defers to manual settle on the Reimbursements tab β€” instead of running the fee-style steps that denormalise onto the tx and duplicate the WOPC. Manual settle (the primary path) is unaffected.

Also synced origin/main into the branch (it was several commits behind β€” AGENTS.md's "group fields into maps" rule, the real T-190, indexes): clean auto-merge, both AGENTS.md rules intact, my duplicate T-190 stayed deleted.

Verified on the merged tree: unfiltered tsc clean Β· vitest 754 passed (+4 new normalizer tests; same 4 known pre-existing billing-ingest) Β· eslint 2 = 2 vs clean HEAD across the touched files.

2026-07-29 β€” #940 merged, DEPLOYED, backfills run (owner command)

βœ… Read AGENTS.md Β· tracking T-178. Owner: "Proceed with the below β€” 1. Deploy 2. backfill-receipt-metadata-groups (dry-run β†’ apply) 3. backfill-2110-settle-ownership (dry-run β†’ apply)". Ran in that order (deploy BEFORE backfills, so the old build never reads the flat keys the first backfill deletes). Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

  • #940 merged to main (c487ef4), closing the T-178 payment-pipeline arc.
  • Deploy β€” production READY: dpl_47CWfsdqXzPoSdnS9M3ULjUFMXXX, commit c487ef42, live at eop.theestablishers.com / pms.theestablishers.com (+ the eop-* aliases).
  • Receipt metadata regroup backfill β€” 109 receipts migrated in tebs-erl (8–18 field paths each), tebs-mel empty (0). Re-run verified idempotent: 0 to migrate Β· 109 already grouped Β· 0 stragglers. Every production receipt is now in the grouped shape.
  • 2110 settle-ownership backfill β€” 0 to flip (0 linked payout txs Β· 0 settled+linked receipts). Correct end state: the reimbursement pipeline is brand new, so no legacy linked settles exist to migrate. The Codex-fixed grouped-first read ran against the freshly-migrated grouped docs.

Now unblocked (step 4, NOT yet done β€” needs the owner's go): both sweeps verified zero stragglers, so the read normalizer's TEMPORARY legacy branch (normalizeStoredReceiptMetadata) can be removed in a follow-up PR. Deliberately left in place until directed β€” it is harmless (grouped docs never hit the legacy path) and removing it is a code change needing its own PR + deploy.

Wrap-up status (2026-07-31)

Audited during the T-198 wrap-up. State of the remaining pieces: - Reimbursement payout tx write (DR 2110 / CR bank) β€” DONE (manual settle path, reimbursementSettle.server.ts). - Receipt β†’ payout-tx surfacing on the receipt drawer β€” the one small unbuilt piece; built in this wrap-up (see SHAs). - Payment-queue-at-issue (Airwallex queue at WOPC issue time) β€” DEFERRED; re-homed to T-184 ("Pay with…" dropdown). - 2110 auto-match-at-sync β€” BLOCKED on T-191/T-193 (the auto-link branch is guarded off by design).

Records surfacing of reimbursement WOPCs shipped under T-197 (WOPC tab) + T-198 (drawer render + paged previewer).

SHA β€” receipt β†’ payout-tx panel (2026-07-31)

ReimbursedBySection added to components/records/ReceiptDetailDrawer.tsx (between the Note and Recent activity sections): for a settled reimbursement receipt it shows settled-on / settled-by plus the payout transaction (date / amount / description), fetched by metadata.settlement.bankTxId via GET /api/accounting/transactions/[id], with an "open β†—" link β€” or a "settled against the default bank account, no specific transaction linked" note when there is no bank-tx id. Read-only; no new API, no Firestore structure change. tsc + tests green.

Company funding account surfaced in the settle step (2026-08-04)

Owner (2026-08-03, reviewing the pay-the-WOPC pipeline): "there were no steps allowing the user to choose which bank account of the company to settle the WOPC. What's your suggestion on this."

The reimburse modal's step β‘’ already picks the payout bank transaction, but it only labelled each candidate with an opaque GL {code} β€” so which company account the money leaves was invisible. That account is not a new choice to store: it is intrinsic to the debit you pick (bankTx.bankAccountId).

Change (components/accounting/ReimbursementsTab.tsx, UI only, no Firestore/journal change): - Each settle candidate now names the company account it left β€” "Airwallex HKD", "OCBC …" β€” resolved from bankTx.bankAccountId via GET /api/accounting/bank-accounts (fetched once; best-effort, falls back to the GL code / raw id on a miss). - A read-back line under β‘’ β€” "Settles from " β€” makes the company account an explicit, confirmed part of issuing, not an implicit consequence of which row was ticked. - Still POSTs the existing /api/receipts/:id/settle; the DR 2110 / CR bank leg stays derived and written once.

Scope note β€” the fresh-payout origination case (fire a payout when no debit exists yet, choosing the company account up front) is the bigger T-199 Phase 3 step and needs the structure gate (where the funding-account choice is stored); flagged there, not built here.

ERL-WOPC/2026-011 β€” production settle verified (read-only, 2026-08-04)

Context: a substitute agent's claude/t-199-phase-3 branch (unmerged) removed the deliberate T-191/T-193 auto-link guard ("BLOCKED … guarded off by design", above) and settled ERL-WOPC/2026-011 directly against production β€” outside the deploy pipeline and the structure gate. Audited the live books read-only (tebs-erl):

  • Payout tx CSU5Mef6hsJbvosrKMDE: GL 2110, HK$2,999.60, ERL-AWX-HKD β€” the only tx in 190 carrying 2110 (no strays / duplicate settle tx).
  • 15 receipts, all confirmed reimbursements backlinked to the WOPC, all settled by that one tx; totals sum to exactly 2,999.60.
  • Derived journals: 15 Γ— DR expense / CR 2110 cleared by exactly one DR 2110 / CR bank (ownership guard held, no receipt emitted a second leg). 2110 nets to zero β€” no double-post.
  • WOPC doc: signed 2026-08-01, payout tx written onto it; reads as paid.

Verdict: the books are correct and balanced β€” nothing to unwind. Process caveat only: the settle bypassed the guardrails, wrote the denormalised payee/abbreviation/wopcRef/memo copies T-191/T-193 are retiring, and left WOPC.status at the legacy 'active'. The dangerous Phase-3 process-payment.ts endpoint is not on main (never deployed). Branch not merged β€” recommend a clean rebuild of the payout path over adopting it.

2026-08-04 β€” CLOSED (done): every T-178 deliverable shipped and live; residuals re-homed

βœ… Read AGENTS.md Β· checked the board by scope Β· tracking T-178. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Status flipped doing β†’ done. Every item in the owner's revised build order has shipped and is in production. The final piece β€” the company funding account named in the settle step β‘’ (2026-08-04, above) β€” deployed today with PR #979 (production READY on eop.theestablishers.com / pms.theestablishers.com), which carried it alongside the T-199 Phase 3 status/retry work.

Build order β€” final state: 1. Match a tx to 5050 against an existing unpaid WOPC β€” DONE (Phase 1, steps 1a/1b). 2. 2110 reimbursement matching module β€” DONE (Phase 2; GL 2110 journal-ownership on the payout tx). 3. Records page integration for reimbursement WOPCs β€” DONE (surfacing shipped under T-197/T-198). 4. WOPC content revised for 2110 (purpose fork) β€” DONE (measured document-layer rebuild). 5–6. Unsigned "Kind regards" closing for non-related-party; sign-then-release kept for directors β€” DONE (derived closing fork; director-reimbursement signing fork + sign-gates-settlement built). 7. Phase 7 catch-all β€” SPLIT OUT: T-184 "Pay with…" dropdown Β· T-185 can't-pay-twice marker (absorbed into T-199) Β· T-186 Vendor-Invoice matching.

Residuals formally re-homed (closing loses nothing): - References-not-copies contract β†’ T-191 (DONE, deployed 2026-07-27) + T-193 (the 2110-must-store-only-wopcRef contract; still blocked, and the 2110 auto-match-at-sync branch stays deliberately guarded off β€” it raises a match_suggestion and defers to manual settle, the shipping path). - WOPC payment lifecycle + 2110 auto-match rebuild + sign-to-pay origination β†’ T-199 (active; Phases 1–2 merged, Phase 3 status/retry deployed 2026-08-04). The clean payout-path rebuild the 2026-08-04 audit recommended proceeds there, behind a structure gate.

Nothing in T-178's own scope remains open; what's left lives in T-193 / T-199 under their own owners and gates.

⚠️ iCloud is canonical (vault README): this flip is written to the repo mirror only. Mirror the same doing β†’ done in the Obsidian vault, or the next iCloudβ†’repo sync will revert it. (Moot as of 2026-08-06 β€” owner: "we don't use Obsidian vault for eons"; the repo board is canonical and the README now says so. Nothing to mirror.)