Skip to content

uid: T-194 title: Reimbursement issuance redesigned to 5050 parity β€” bank-account picker, real reference, full document status: done area: accounting created: 2026-07-29 updated: 2026-07-31 owner: girafeev1 related: T-178, T-183


T-194 β€” Reimbursement issuance, brought up to the 5050 standard

Why

The T-178 reimbursement issue modal was a lightweight preview β€” a passive, clipped document with a placeholder reference and no way to confirm which bank account the money goes to. The owner compared it to the contractor-fee (5050) Payment Confirmation flow (2026-07-29): "how come when I create a WOPC for 5050, it shows a fully rendered WOPC with the latest ref no. and steps provided to input payee information… the payee is selected, but there're still information that needs to be confirmed, such as bank account as some payee has multiple bank accounts on records."

Reviewed and approved as a redesign preview (an interactive mockup) and a genuine rendered WOPC sample (real component, 15 real receipts) before any code β€” owner: "Much better. Proceed to implement."

What changes (approved design)

  • Bank-account picker. The selected employee's accounts are offered like 5050's Stored Bank Account select β€” each shown Bank (code) β€’ β€’β€’β€’β€’NNN β€’ Holder, a purple Preferred tag on the default. Today the reimbursement path silently uses the default account; now the operator confirms.
  • Real reference number. The preview shows the live next ERL-WOPC/YYYY-NNN, peeked (read, never reserved β€” peekNextWOPCNumber, the exact mechanism 5050 uses) and only minted at issue, so a cancelled preview never leaves a gap in the shared series. (The earlier β€’β€’β€’ placeholder is retired.)
  • Full, scrollable document. The preview becomes a proper scroll of the real multi-page document β€” every line item across every page, no clipping. Replaces the old scale(0.6)-in-a-clipped-box that hid page 2.
  • Payee bank block filled from the chosen account (was "greatly missing").
  • Side-by-side kept β€” selected receipts beside the live document at the final step (owner likes it).
  • Recipient-address spacing loosened β€” layout.ts ADDR_PITCH 11 β†’ 13.2 (line-height 1.0 β†’ 1.2, ~0.2 looser). A deliberate departure from the measured Google-Doc value (owner-directed); applies to every WOPC's recipient block, not just reimbursements.

Security contract (client never holds full account numbers)

The feed sends the payee abbreviation and a masked account list only β€” { identifier, bankName, bankCode, accountLast4, accountHolderName, isDefault }. The operator picks; the client sends back the chosen bankAccountIdentifier; the issue endpoint resolves it to the full account server-side (bankOf(ind, name, identifier)) for the document + payout. Full account numbers stay on the server.

Decisions

  • Bank-picker default: pre-select the account when the payee has exactly one on file (shown, changeable); require an explicit pick when there are several. (Owner's multi-account concern.)
  • Reference via peek, not reserve β€” mirrors 5050; no gap risk.
  • Recipient spacing is a deliberate departure from the measured template (line-height 1.0 β†’ 1.2), reviewed on a rendered sample.

Firestore

No structure change. The WOPC already stores its bank block; choosing which account fills it does not change any document shape. Feed additions are read-only; the issue body gains one optional string.

Window-envelope positioning (general, 2026-07-29)

Owner chose to mail on a DL / Hong Kong No. 5 (5号) window envelope, 110 Γ— 220 mm, A4 folded in thirds. The No. 5 window is ~100 Γ— 40 mm in the lower-left; exact offsets vary by maker and aren't reliably published, so the recipient block is positioned to the DIN 5008 standard the window is cut to accept β€” left-aligned at the ~20 mm body margin β€” and the "To:" label was dropped (owner: "Let's remove the To:"; the window addresses the letter). This is a general position, reviewed on a rendered sample; the exact vertical offset and fine left position are dialled in later against a physical envelope (print β†’ fold in thirds β†’ check the window β†’ nudge). Research + reasoning: [[decisions]] / the session. Change: PaymentConfirmation.tsx recipient block left-aligned to MARGIN_X, "To:" removed β€” applies to every WOPC.

Files

lib/paymentConfirmation/layout.ts (spacing) Β· lib/accounting/reimbursementPayee.server.ts (pick by identifier + masked mapper) Β· pages/api/accounting/reimbursements.ts (feed: abbreviation + masked accounts) Β· pages/api/accounting/reimbursements/issue.ts (accept identifier) Β· components/accounting/ReimbursementsTab.tsx + buildPreview (picker, peeked ref, scrollable preview, wiring).

Source

Design reviewed and approved over the Records session, 2026-07-29 (mockup + genuine rendered WOPC sample). Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

2026-07-29 β€” Built (pending deploy + in-app verification)

All six changes landed on claude/friendly-newton-bLnSw (ff1f10c spacing + task; f435557 feature). The feed sends the payee abbreviation + masked accounts; the modal gained the "Confirm bank account" picker (pre-select single, require pick when several, Preferred tag), the live peeked reference number, the chosen account wired into buildPreview + the issue POST (bankAccountIdentifier), and a scrollable full-document preview (overflow:auto + zoom); the issue endpoint + bankOf re-resolve the account server-side from the identifier. Director signature fork, awaitingSignature disabling and readiness gating unchanged.

Verified: unfiltered tsc clean Β· eslint 0 = 0 on the four changed files Β· vitest 754 passed (same 4 known pre-existing billing-ingest). Not yet verified in-app β€” the modal's picker/preview should be eyeballed once deployed (the build env can't run the front end). No Firestore structure change.

Correction (2026-07-31): the line above once read "Window-envelope positioning remains deferred pending the envelope spec" β€” that is stale. Window-envelope positioning shipped in the 2026-07-30 round below: the recipient block sits at RECIPIENT_TOP_PAGED on every page, left-aligned to MARGIN_X (DIN 5008), with the "To:" label removed, so the address lands in the No.5 window (PaymentConfirmation.tsx:477-483, 534-546; layout.ts:47-58). All that remains is physical calibration β€” printing, folding in thirds, and nudging the exact offset against a real envelope β€” which is an owner measurement task, not code. Task marked done on that basis.

2026-07-30 β€” Reimbursement document refinements (reviewed on a rendered sample)

Calibrated over two review rounds against genuine WOPCs rendered from real receipts β€” a paginated 20-receipt document (splits 10 / 10) and 8-receipt single pages (signed + unsigned) β€” then landed on claude/friendly-newton-bLnSw. The vertical rhythm below is the settled result of the second round.

  • Category prints as its documentation label. A receipt line's category rendered its stored slug (fnb, out_job_consumables); it now prints the shared documentation label (F&B, Out-Job Consumables) via categoryLabelFor β€” the same map the Reimbursements table already shows in its own columns, so the preview, the issued document and the on-screen list all read alike. Applied where the line-item feeType snapshot is composed (issue endpoint + buildPreview), before the date is joined on; an uncategorised receipt still shows only its date. The snapshot stores the words the WOPC prints, per the T-178 snapshot rule.
  • Paginated first page β€” dropped list, centred opening. A paginated reimbursement's first page lists ≀10 single-line rows and carries no closing, so it is never packed. Its receipt table + intro line sit two body-lines lower (reimbursementHeadDrop, first page only), and its opening β€” salutation + body paragraph β€” is CENTRED between the recipient block and the intro so the air above and below it is equal (reimbursementOpeningTop). The recipient block stays put at the top for the envelope window.
  • Recipient at a consistent height for the window. A reimbursement's recipient block sits at the paginated height (RECIPIENT_TOP_PAGED) on every page β€” one-pager or multi-pager β€” so the address lands in the same No.5 window-envelope window regardless of page count (owner: the measured single-page position sat too high for the window). Contractor fees keep the measured single/paged split.
  • Single page β€” body shifted down two lines. With the recipient now lower, the salutation was cramped against it, so on a one-pager everything below the recipient β€” salutation, body, intro, table, total, payment block (and the unsigned closing) β€” sits two lines lower (REIMBURSEMENT_SINGLE_SHIFT), the footer fixed, giving the recipient a generous band of air beneath it. This lowers the one-pager capacity from nine rows to eight (a ninth receipt paginates), which is why a reimbursement one-pager measures its own capacity directly against the footer (maxReimbursementSingleRows) rather than through the anchored contractor model β€” its closing follows the payment block, not the template's bottom anchor. Contractor-fee WOPCs never move.
  • Closing follows the payment block, by page role and closing. The closing was pinned to the foot of the page, leaving an empty band above it on a short document. A reimbursement's closing now follows the bank block β€” a full payment-block line below it on an unsigned one-pager (owner: "remove that line… move [the closing] closer to the bottom of the payee's bank information"); pulled a line tighter, hugging the bank block, on a SIGNED one-pager (singleClosingGap), because the taller signed closing would otherwise be carried into the footer by the two-line body shift (owner: "a line lower as well, but then afterwards, make the closing a line closer to the height of the bottom of the payee bank info"); and three lines below it on a busy paginated last page (ten rows + total). The trailing whitespace falls beneath the closing β€” the letter reads signature-under-details, blank at the foot. Contractor fees keep the measured template's bottom anchor. The closing block bottom clears the footer rule for every row count and both closings (unit-tested).
  • Files: layout.ts (REIMBURSEMENT_HEAD_DROP/REIMBURSEMENT_SINGLE_SHIFT/reimbursementHeadDrop, reimbursementOpeningTop, reimbursementClosingTop + REIMBURSEMENT_CLOSING_GAP + singleClosingGap, maxReimbursementSingleRows + its use in planWopcPages), PaymentConfirmation.tsx (recipient at the paginated height for reimbursements; first-page opening centred + intro/table dropped two lines; single-page body shifted two lines; renderClosing takes the closing-aware anchor), layout.test.ts.

Verified: unfiltered tsc clean Β· eslint 0 = 0 on the changed files Β· vitest 762 passed (same 4 known pre-existing billing-ingest). No Firestore structure change.

2026-07-30 β€” Post-deploy document polish (reviewed on rendered samples)

Live-preview fixes after the production deploy, on a fresh branch off main:

  • Middle page holds 20 rows, list centred. A paginated reimbursement's middle page β€” a whole page of one-line receipts, no recipient / total / closing β€” now packs 20 rows (REIMBURSEMENT_CAP_MIDDLE, vs the contractor's taller-row 15), and centres the list vertically between the "Page x of y" line and the footer (reimbursementMiddleTableTop), so a partly-filled middle page sits balanced. A 40-receipt reimbursement is now 10 / 20 / 10, not 10 / 15 / 15 / 10. First and last pages keep their tighter caps.
  • Amount column pins the "$". Reimbursement figures right-align while the "$" sits in a fixed left column (w-amt-split), so the signs line up vertically instead of drifting with the number's length.
  • Total-row "(HKD)" no longer wraps (white-space:nowrap).

Files: layout.ts (REIMBURSEMENT_CAP_MIDDLE, paginateWopcRows mid-cap param, reimbursementMiddleTableTop), PaymentConfirmation.tsx (renderAmount split cell, centred middle table, .w-cur nowrap), layout.test.ts. Verified: unfiltered tsc clean Β· eslint 0 = 0 Β· vitest 765 passed (same 4 pre-existing). No Firestore change.

2026-07-30 β€” Hotfix: recipient address + single-account bank were blank

Reported live: a reimbursement WOPC showed no recipient address and no bank block. Two gaps:

  • Recipient address was never populated. The issue endpoint never wrote contractorAddress, and the modal preview hard-coded it to {}. Fixed by mapping the payee's Contacts address (individual.basic.address β†’ wopcAddressOf β†’ the document's line1 / line2 / line3 / country, joining district + region into the third line): resolveReimbursementPayee now returns it (issue writes it to the WOPC), and the feed sends payeeAddress so buildPreview fills it too. Verified against live data β€” e.g. JC and JN both carry a full address, now rendered.
  • The single-account pre-select was documented but never coded. selectedBankIdentifier started null and nothing set it, so even a one-account payee showed no bank until a manual pick. Added the effect that pre-selects the sole account (and clears a stale pick when the payee changes; idempotent, so a background refresh never clobbers a manual multi-account pick). A payee with several accounts (JC has four) still requires the deliberate pick, per the T-194 decision β€” the block fills once one is chosen.

Files: reimbursementPayee.server.ts (WopcRecipientAddress + wopcAddressOf + address on the resolved payee), reimbursements/issue.ts (write contractorAddress), reimbursements.ts (feed payeeAddress), ReimbursementsTab.tsx (preview uses it; bank pre-select effect). tsc clean Β· eslint 0=0 Β· vitest 765 passed.

  • Recipient name was the account handle, not the legal name. The block + salutation printed the RBAC account's display name ("Jeff") β€” the preview took it from the submitter displayName (reimbursements.ts p.displayName), and the issued doc's nameOf dropped the legal name's title. Now both use formalNameOf β€” title + first + last from Contacts (legal.legalName), e.g. "Mr. Jeffero Chan": resolveReimbursementPayee exposes formalName (issue writes it to contractorName), and the feed sends the group's formal payeeName so the preview salutes formally too (falling back to the display name only when the payee is unlinked). The bank holder was already correct β€” it reads the account's own accountHolderName, a separate field. Verified against JC's live record ({title:"Mr.", firstName:"Jeffero", lastName:"Chan"} β†’ "Mr. Jeffero Chan").