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)¶
- One WOPC type, shared numbering. No new type, no
ERL-PCseries. SameERL-WOPC/YYYY-NNNreference 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".) - 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."
- Unsigned closing mirrors the client invoice email (owner: "make the closing of the WOPC the same as the invoice mail being sent to clients"): "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.
- 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.
- 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_transactionvsactive+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"). - 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)¶
- 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.
- Referencing the 5050 module, improve (or build) the 2110 matching module following the current module design (customization, no hardcoding).
- Integrate the Records page to facilitate WOPC for reimbursement.
- 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.
- WOPC closing for non-signing (no director assigned) WOPCs and WOPC for receipt reimbursement.
- Unsigned "Kind regards" closing for non-related-party; keep sign-then-release for related parties.
- 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: incurDR expense / CR 2110, settleDR 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) + rootwopcReferenceNumber;updateWOPCMatchedtransitionspending_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 stillpending_transaction(notransaction.id), not voided/deleted, lean picker fields incl. a derivedsigningStatus(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 callsmatchOneWopcTransaction(byte-identical GL assign + txβWOPC backref +pending_transactionβactive+ notification as the auto path).lib/accounting/wopcAutoMatch.server.tsβmatchOneWopcTransactiongainsallowAmountMismatch?(auto path never sets it; the manual UI passes it only after the operator confirms a shown mismatch). Gate: full unfilteredtsc --noEmit --incremental falseclean on the project's TS 5.9.3 (had tonpm installfirst βnode_moduleswas absent andnpxpulled TS 6 which errors on the repo'smoduleResolution=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 whenallowAmountMismatchis unset. No schema change; no UI yet. β οΈ Repo hygiene handoff (not mine):package-lock.jsononmainis OUT OF SYNC withpackage.json(missingesbuild@0.28.1,yaml@2.9.0+ platform variants) βnpm cirefuses, 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, defaultcreateβ 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 (mirrorsBillingLinkModal), 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 β postsallowAmountMismatch:true) βPOST /api/accounting/wopc/link-transaction. - Host (
TransactionLinkingModal.tsx): +onLinked?prop onuseWopcInline; on success it re-fetches the tx viaGET /api/accounting/transactions/[id]βonRefreshTransactionβonClose(mirrors the create path). Gate: full unfilteredtsc --noEmit --incremental falseclean. 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 (Vercelnpm 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 2110on receiptβconfirm (derivedJournals.server.ts:769), settleDR 2110 / CR bankwhen the receipt records which bank tx paid the employee (:835, driven bysettledBankTxId). The expense always lands in its category GL in both pipelines (companyβcard =DR category / CR bankat match viacategorizeTxFromReceipt, 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 β theDR 2110 / CR bankleg stays derived, written once; nothing doubleβposts). Gate: full unfilteredtsc --noEmit --incremental falseclean. Scope decisions: the optional serverβsideassign-accountguard 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 > 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 ' β
font-family: 'EB Garamond' 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. Carriesabbreviation(the same UPPERCASE key payees use),legal.bankAccounts,system.userUid(bridge to the RBAC account) andsystem.position.usersβ the auth/RBAC account. A reimbursement receipt storesuploadedBy= email; the reimbursements API resolves the display name throughgetUserProfileByEmail.payeesβ the abbreviation-keyed recordcreateWOPCrequires.
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/mereturns{ email, displayName, position }, resolving the caller's Individual bysystem.userUid. No id parameter, so it cannot read anyone else's record; - the Reimbursements tab reads it and passes
positionas 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[]
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[]
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
}
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'joinsGL_RESERVED_KEYSin the same change β the trap this migration had to respect is thataccountCodeis derived from theglmap's keys, so an unreservedreceiptIdskey would read as an account code named "receiptIds". The rootTransactionDocument.receiptIdsstays 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 viamatchReceiptToTransaction) now read the union of both locations and write onlygl.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βtoV1readsgl.receiptIds ?? receiptIds(fallback marked TEMPORARY);prepareForWritekeeps a legacy-tolerance passthrough so a pre-backfill document round-tripped through it cannot lose links. The V1/UI shape is unchanged βv1.receiptIdsstays 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,--applyto 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):
- Deploy β hook fired (job
9J689jN47AnEEf3dvU7a), production deploymentdpl_FysF29VhRedunTWtTPZUTzEpbrvDREADY on commit64977fdβ that is #941 (the Telegram agent's I-037 CJK prompt fix) merged directly on top of ourf2d310a, verified as its descendant, so everything from #933 is live: the WOPC restyle + pagination, the Reimbursements rework, payment readiness, the split tabs, postage, and thegl.receiptIdsbuild. - Backfill (
scripts/backfill-receiptids-under-gl.ts, run only after READY): dry-run showed 184 transactions scanned Β· 10 carrying a rootreceiptIdsβ 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 emptyglkeys), then--applyran: 2 migrated, 8 cleaned. Verification dry-run after: 0 left to migrate, 2 already ingl, 182 with no links. Production is fully in the approved shape. - Fallback removal (the contract step, in this commit):
toV1readsgl.receiptIdsonly;prepareForWrite's root passthrough removed; the LEGACYTransactionDocument.receiptIdstype field deleted (the adapter test fixture moved with it).unionReceiptIdskeeps 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__dirnamebreaks under ESM loaders (ts-node-transpile-onlyis what actually runs here;npx tsxisn't installed) β now resolves.env.localfrom 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:
- 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.
- 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.
- 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).
2026-07-28 β Receipt regroup shipped Β· backlink live Β· sign-gate enforced Β· processed receipts locked¶
β 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:
- 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). - 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:
- As built β
gl.receiptIdscarries 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. - 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". - 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 missingsettlement.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 queryingmetadata.settlement.bankTxId == txId(new readerfindReceiptIdsSettledByTransaction) instead of reading a storedgl.receiptIdsarray. - The ownership backfill (
backfill-2110-settle-ownership.ts) flips onlygl['2110']; it writes nogl.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:
-
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.
-
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 onlywopcRef** β 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.tsandreimbursements.tsread 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) andgetWOPCByReferenceNumber'sas WOPCDocumentcast 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 canonicaltoView()accessor. (This is the sign-gate feature; it was shipping switched off.) - P1 β a partially-regrouped receipt dropped out of every filter.
patchParsedReceiptstampsreview.editedon every write butreview.stateonly when a new state is passed, so a wopcRef backlink on a legacy receipt created areviewmap with nostate; the normalizer then ignored the legacy flatreviewState, 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
reviewStatethe normalized reads no longer see; moved tometadata.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, commitc487ef42, live ateop.theestablishers.com/pms.theestablishers.com(+ the eop-* aliases). - Receipt metadata regroup backfill β 109 receipts migrated in
tebs-erl(8β18 field paths each),tebs-melempty (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 /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 2110cleared by exactly oneDR 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.)