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_PITCH11 β 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_PAGEDon every page, left-aligned toMARGIN_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) viacategoryLabelForβ 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-itemfeeTypesnapshot 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 inplanWopcPages),PaymentConfirmation.tsx(recipient at the paginated height for reimbursements; first-page opening centred + intro/table dropped two lines; single-page body shifted two lines;renderClosingtakes 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'sline1 / line2 / line3 / country, joiningdistrict+regioninto the third line):resolveReimbursementPayeenow returns it (issue writes it to the WOPC), and the feed sendspayeeAddresssobuildPreviewfills 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.
selectedBankIdentifierstarted 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.tsp.displayName), and the issued doc'snameOfdropped the legal name's title. Now both useformalNameOfβ title + first + last from Contacts (legal.legalName), e.g. "Mr. Jeffero Chan":resolveReimbursementPayeeexposesformalName(issue writes it tocontractorName), and the feed sends the group's formalpayeeNameso 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 ownaccountHolderName, a separate field. Verified against JC's live record ({title:"Mr.", firstName:"Jeffero", lastName:"Chan"}β "Mr. Jeffero Chan").