Skip to content

IR56M fill → review → confirm → sign (Filings tab, reuse WOPC signing)

2026-06-29 — Accounting IR56M tab: per-recipient signer + Review/Edit → Preview → Request Signature

Attestation: read AGENTS.md + checked the board by scope — extends THIS task (IR56M fill→review→sign); the Accounting-tab capture flow is the front half of T-047, so recorded here, not a new task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code). Proposed by: owner. Approved by: owner (decisions quoted below).

Owner request (verbatim, 2026-06-29):

"In the IR56M tab on the Accounting page: 1. The choosing of the Signing Director shouldn't be a universal setting in a dropdown for all IR56M. It should be rebranded as 'Signing Director by Default'. 2. The filing of the IR56M … shouldn't be controlled by a 'File? toggle'. a) Remove the file column and the button b) Make the actions column to have only 2 buttons: 'Review/ Edit' & 'Request Signature' c) Upon clicking Review/ Edit … show the fields as is now, PLUS the choosing of the signing director … d) paginate to a second page that shows the PDF in a previewer … e) at the [bottom] of the preview page, show 3 buttons: Cancel, Back … and Request Signature. … Request Signature would be to show a modal including the email (like the requesting of signature for a Project Invoice …) to … read the email that will be sent, and to confirm the sending out of it."

Decisions (owner, 2026-06-29 — via clarifying questions): 1. Timing gate"Keep YA-closed gate": Request Signature only enables once the YA fully closes (figures frozen), matching the Records gate. The signing backend never enforced it (UI-only), so the gate is the seed API's existing yaClosed read in the Accounting tab. 2. Save model"Request Signature commits + saves. If user tries to close the IR56M — modal (by either clicking cancel or pressing 'ESC'), a prompt should ask whether the user would like the information saved": no File? toggle, no batch Save button. A recipient is committed to the saved filing by Saving from the modal's close-prompt OR by Request Signature. 3. Records 'Request signing'"Keep both": Accounting gets the rich review→preview→confirm entry; Records keeps its own quick request + the lifecycle (Sign now / Withdraw / Distribute / Void).

What shipped (5 files): - lib/taxHK/ir56m/filing.tssigningDirectorId? on IR56MRecipientFiling (per-recipient signer; overrides the filing default; drives the printed block + the request email). Persisted as-is by saveIR56MFilingServer (no server change); the seed API already returned yaClosed. - components/accounting/tax/IR56MFormPdfPreview.tsx (NEW) — client-only react-pdf viewer (dynamic, ssr:false), mirroring the IR56M sign-flow preview. - components/accounting/tax/IR56MRecipientModal.tsx — paginated: page 1 (fields + signing-director picker) → page 2 (embedded PDF preview); footers [Cancel·Preview] / [Cancel·Back·Request Signature]; Cancel/ESC → Save / Discard / Keep-editing prompt; Request Signature disabled until yaClosed. - components/accounting/tax/IR56MRequestSignatureModal.tsx (NEW) — email-confirm modal mirroring SendInvoiceModal; on confirm reuses createOrGetIR56MSigningRequest + sendIR56MForSigning (the SAME backend the Records flow uses). - components/accounting/tax/IR56MFilingTab.tsx — rebranded dropdown → "Signing Director by Default"; removed File? column + batch Save; Actions = Review/Edit + Request Signature (status-aware, gated); per-recipient director default + commit-on-save/request persistence.

Verified: tsc clean on all 5 files (--max-old-space-size=8192, exit checked). Runtime UI verification PENDING — the local dev preview is auth-gated, and the NAS deploy is currently blocked by 2 pre-existing type errors in lib/wopc.server.ts (from 6dfb0a1d / #806) that fail next build (no ignoreBuildErrors). Flagged to the owner.

Blast radius: Accounting → Tax → IR56M tab + the IR56M recipient/filing data shape (added optional signingDirectorId). Reuses the existing signing-request backend (no new endpoints); writes the shared ir56mFilings store via the existing save path. The Records IR56M tab is untouched (coexists).

2026-06-24 — chop: shuffle-variant button + verified 20 mm diameter (WOPC + IR56M)

Attestation: read AGENTS.md + checked the board by scope — refines the shared chop step (WOPC + IR56M signing + the sign-test), not a new/duplicate task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).

Two owner asks:

(1) Shuffle button — change the chop variant without re-placing.

Owner: "can you create a button for user to switch to another random chop? … a shuffle button … while rendering to the user tapped or clicked position, allow user to shuffle the chop to another variant"

Added a "Shuffle chop" button to the WOPC chop page, the IR56M chop page, and the sign-test. It re-rolls to a different variant (excludes the current) while KEEPING the placement — both the cursor-follow preview (desktop, pre-place) and the placed marker (any device, post-place) key off variant, so the new seal appears immediately at the same spot. Shown when (!isCoarse || placed): desktop sees it pre-place (preview = what stamps); mobile sees it after the first tap.

(2) Verify the chop renders at 20 mm diameter (owner felt some looked ~22 mm).

Owner: "I feel like I'm seeing some of the variant being rendered at 22 mm … make sure … the rendered chop is at 20 mm of diameter"

Audited ALL 11 ERL chops (downloaded from GCS + measured + viewed). Findings: each is a clean transparent-background circular seal that fills a square 1024² canvas (the trim() is a no-op because the ring touches the canvas edges — correctly). They render consistently; there is no 22 mm variant in the app. The "22mm" is the source-asset FOLDER name (doc/stamps/authorization/erl/22mm/ — the physical chop family), which the app scales down to CHOP_DIAMETER_PT. Two fixes so the render is provably 20.00 mm: - CHOP_DIAMETER_PT made exact(20×72)/25.4 = 56.69 pt. The old Math.round → 57 pt = 20.108 mm (a hair over). Synced across the composer + all 3 client previews. - composeChopOntoPdf now fits the seal's longest side to 20 mm (was: width→20 mm, height = width×aspect, so a non-square trim rendered TALLER than 20 mm — the only path to >20 mm). Aspect preserved (no distortion).

Verified. tsc clean. Replaying the real composition sizing (trim + longest-fit) on all 11 variants → every one renders 20.00 × 20.00 mm. If 22 mm still appears on a printed PDF, that's the viewer/printer scale (not 100%), not the composition.

Blast radius. The shared chop step (lib/wopc/signing/composition.server.ts composeChopOntoPdf + CHOP_DIAMETER_PT) — affects BOTH WOPC and IR56M signing. Client chop pages + the sign-test get the matching exact diameter + the shuffle button. No data/wire changes.

2026-06-24 — desktop "sign on the document" editor for IR56M + chop-page load fix

Attestation: read AGENTS.md + checked the board by scope — extends this task's Phase-4 signing UX (desktop parity with WOPC + a regression fix), not a new/duplicate task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).

Two owner reports this session, both fixed:

(1) Bug — IR56M "doesn't load" on the chop page after signing (mobile). Root cause: the portrait guard added earlier today (RequirePortrait) replaced the page with the rotate prompt in phone-landscape, which UNMOUNTED the chop/preview PDF container. Those pages size their PDF from a container measured in a mount-time effect; unmounting ran that effect against a null ref and it never re-fired on rotation, so the PDF never got a width and hung on a spinner. Fix: the guard now OVERLAYS the prompt (position:fixed) with children kept mounted + measured behind it — rotating reveals the already-sized page. (Same "render it, toggle visibility, don't unmount" lesson as the coaching dialog tabs.) Shipped 4c01cb4e.

Owner: "the IR56M doesn't load at all after I've finished the signing and have arrived the chopping page"

(2) Feature — desktop signs ON the form, like WOPC. IR56M sent every device to the mobile full-screen draw canvas. Now the method page (and the sign-test harness) route by device — phone → draw canvas; tablet/desktop → a new editor that renders the filled IR56M and lets the director draw straight onto it — matching WOPC's "Sign on the document".

Owner: "there's a desktop version of signing where the web app presents the whole form to the user and for the user to sign on it directly. Can we make it so that if the signing process is opened on desktop, then we do that directly instead of providing the dedicated signing page that's supposed to be for mobile device only?"

Proposed by EOP Local Assistance (fork); approved by the owner (the two reports above).

How #2 is built. New reusable components/wopc/Ir56mSignEditor.tsx (renders a PDF + a transparent draw overlay; mirrors WOPC's editor) drives BOTH the real wizard (app/ir56m/sign/[id]/editor) and the sign-test harness, so they can't drift. On Done it stores a full-page PNG + placement:{mode:'overlay-page'}; composeSignedIR56MPdf gained an overlay-page branch that draws it 1:1 over the page (the fixed-box + signing-line path from #2 is the else). placement threads through CapturedSignature → /chop, /preview, /sign (+ /test) exactly like baselineFraction. Routing keys off useIsCoarsePointer && !useIsTablet.

Verified. tsc clean across all changed files (lone error = the pre-existing croner install gap in an untouched cron file). Editor + overlay-page composition mirror the proven WOPC editor path.

Blast radius. IR56M signing only, plus two shared components: RequirePortrait (the overlay change touches ALL its consumers — WOPC + IR56M method/chop/preview — but is strictly SAFER: it stops unmounting, so nothing can regress) and FreehandSignaturePad (unchanged behaviour). WOPC signing untouched.

2026-06-24 — #2: the on-screen signing line now DRIVES signature placement (IR56M)

Attestation: read AGENTS.md + checked the board by scope — a refinement of this task's Phase-4 signing UX, not a new/duplicate task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).

What & why. The draw screen already had a draggable "signing line", but it was decorative — the cropped signature was stamped at a FIXED offset above the 簽署 rule, ignoring where the director put the line. Now the line is the placement reference: where it falls within the signature maps to the document's rule.

Owner request (verbatim):

"The line shown on the sign screen is still not used as the center point for the signature rendering… use the placeholder line on my signing screen as a center point to determine the rendering height."

and the corrected direction (the decisive constraint):

"if the line on the signing page gets drag down, the signature should appear higher and vice versa"

Build go-ahead: owner, "yes please" (2026-06-24). Proposed by EOP Local Assistance (fork); approved by the owner.

How. On Done the canvas computes f = (lineY − inkCropTop) / inkCropHeight — where the line sits within the cropped ink (0 = top edge, 1 = bottom edge) — and threads it through writeStoredSignature → /chop, /preview, /sign (+ /test) → composeSignedIR56MPdf, which places the signature centre at centreY = RULE_Y + (f − 0.5)·height (clamped on-page). INVERSE by construction: line dragged DOWN ⇒ more ink above it ⇒ larger f ⇒ signature HIGHER on the page (pdf y-up) — exactly the owner's constraint. f=0.5 centres on the rule; absent f (saved-upload) keeps the original fixed +5pt offset (backward-compatible).

Verified. tsc clean across all 12 changed files (lone error = a pre-existing croner install gap in an untouched cron file). Formula unit-checked: monotonic drag-down→higher PASS, f=0.5→on-rule PASS, absent→old-offset PASS, extreme f clamps on-page PASS.

Blast radius. IR56M signing compose path + the shared components/wopc/FreehandSignaturePad.tsx (additive getInkMetrics; toCroppedDataURL refactored to SHARE the crop math, behaviour identical, so the WOPC draw/editor that also use the pad are unaffected). WOPC signing NOT changed: its mobile draw places the signature via the HTML template's fixed .pc-signature-space slot (the composeSignedWopcPdf fixed-box branch is dead legacy), so the WOPC signing line stays decorative until that template is taught the baseline — a separate, larger change. Flagged to the owner as a follow-up.

STATUS 2026-06-19 — resumed; JC confirmed a reportable sub-contractor (intent ON)

Everything built so far is COMMITTED + PROMOTED TO MAIN (no longer "not committed"): T-045 research, T-045a candidate scan (wired into the WPP), T-047a particulars storage + HKID input, T-047c PDF fill engine. Owner gave the go-ahead to build the rest in 3 phases: - Phase 1 — DONE (origin/nightly c802b81f). POST /api/accounting/ir56m/fill (auth-gated; IR56MFormData → fillIR56MPdf → application/pdf) + next.config outputFileTracingIncludes (traces mupdf's wasm + the IRD template). mupdf-in-Next DE-RISKED: next build compiled the route as a serverless function — mupdf's ESM + wasm + top-level-await bundle cleanly. Residual: one authenticated runtime hit on the nightly deploy to confirm wasm loads + template is traced. - Phase 2 — DONE (nightly; owner-verify the tab on the deploy). - 2a DONE (nightly 83d18cd7→2594d460): lib/taxHK/ir56m/filing.ts (pure: IR56MPayerSnapshot / IR56MRecipientFiling / assembleIR56MFormData / totalRemuneration / validatePeriodWithinYA) + filings.server.ts (tebs-erl/ir56mFilings/{endingYear} CRUD, mirrors taxFilings, history[] audit) + 9 unit tests. - 2b DONE (nightly 05f376a0): seed.server.ts buildIR56MFilingSeed (candidate scan + payee taxProfile + payer → pre-filled recipients; identity + period seeded, capacity/amounts left to owner; detect-don't-enforce) + pages/api/accounting/ir56m/filing.ts (GET seed+existing, POST save; not period-gated). - 2c DONE (nightly 243e94d0) — the Filings-tab UI. components/accounting/tax/IR56MFilingTab.tsx + IR56MRecipientModal.tsx; registered in AccountingApp (Layered "Filings" group, order 41). Pick YA (global period selector; All-time prompts to pick) → candidate table (every payee w/ a YA outflow, threshold-flagged, "File?" toggle) → per-recipient capture modal (capacity / period [YA-constrained DatePickers] / category split / withheld / remarks + particulars pre-filled, editable) → preview via /api/accounting/ir56m/fill (opens the filled PDF) → Save via POST /filing (resumes drafts). tsc 0; suite 340; eslint clean. - Phase 3. Signing — reuse WOPC request/sign/chop/upload for an IR56M doc-type + the new "sign from Records" surface; signed PDF → Drive "14d. IR56M". (The "OPEN RISK" section below = RESOLVED by phase 1.)

DECISION: fill the OFFICIAL IRD PDF via mupdf-wasm (owner, 2026-06-15)

The official IR56M PDF is AES-256 (V5/R6) with an EMPTY user password (opens freely; owner-password restricts editing; form-filling permitted). pdf-lib can't decrypt it; mupdf (wasm) opens it transparently, the 61 AcroForm field names are fully readable + self-documenting, and saveToBuffer('decrypt') after bake() yields a decrypted, flattened PDF that pdf-lib can then sign. Added dep: mupdf.

T-047c PDF ENGINE — BUILT + VERIFIED + COMMITTED (2d15d4d7, on main) + ROUTE ADDED (phase 1)

  • lib/taxHK/ir56m/ir56m-template.pdf (official IRD form, committed as the fill template).
  • lib/taxHK/ir56m/fields.ts (pure): IR56MFormData + exact field-name map + buildIR56MFieldValues (text + capacity/title checkboxes; money = whole HK$).
  • lib/taxHK/ir56m/fillIR56MPdf.server.ts: mupdf open → set fields by name → bake → saveToBuffer('decrypt') → Uint8Array.
  • lib/taxHK/ir56m/mupdf.d.ts: ambient shim (project moduleResolution can't see mupdf's own types). Engine has NO import 'server-only' (would throw in pages/api).
  • VERIFIED against the owner's real sample (Ngai Wang Chi → Chan Jeffero/陳彥廷, HKID Y360417(7), subcontractor, 03/09/2024–30/03/2025, $134,800): all field VALUES set (pre-bake read-back), decrypted output opens in pdf-lib, page renders correctly. tsc 0 err, lint clean. Sent the owner the filled sample PDF for visual confirmation.
  • Field names (mupdf-confirmed): Reporting Year · Sheet Number · Section Number of Employer's File · Employer's File Number · Name/Address of Payer · Name/BRN of Partnership… · Name/BRN of Sole-proprietorship · Title-Mr/Ms/Miss (checkbox) · English/ Chinese Name of Recipient · HKID Number-Prefix/Digits/Check Digit · Indicator-Sex · Indicator-Marital Status · Name of Recipient's Spouse · Spouse's HKID… · Recipient's Postal Address/Telephone Number · Capacity-Subcontractor/Agent/Writer/Consultant/Coach/ Tutor/Others (checkbox) + Capacity Engaged · Start/End Date · Amount-Subcontracting Fees/ Commission/Writer's…/Artiste's…/Copyright…/Consultancy…/Service Fees · Nature/Amount of Other Income · Amount-Total Incomes · Indicator/Amount-Sum Withheld · Remarks · Name of signer · Designation · Date of Signing · Indicator-Form Completed.

~~OPEN RISK~~ RESOLVED (phase 1, c802b81f): Next.js integration of mupdf

mupdf is ESM with top-level await + wasm. ~~NOT yet validated in a Next build/route.~~ RESOLVED: the phase-1 route /api/accounting/ir56m/fill builds — next build compiled it as a serverless function (mupdf ESM + wasm + TLA bundle cleanly). outputFileTracingIncludes traces the wasm + template into the function. Residual = a runtime hit on the Vercel/nightly deploy.

Goal (owner, 2026-06-15)

A Layered-view Filings tab (the group already exists — Profits Tax tax lives there) to fill an IR56M, review + confirm, then sign it — mirroring WOPC: pick a "closing director" who signs by (a) logging in → Records → sign in-app, (b) emailed signature request, (c) company chop/seal, or (d) manual upload of a signed form. Owner attached blank IR56M.pdf + IR56M_Filled.pdf; IRD form (4/2026): https://www.ird.gov.hk/chi/pdf/ir56m.pdf

Form fields learned (IR56M 4/2026)

Header: 截至 ___ 年 3 月 31 日止的 1 年度內 = year of assessment (year ending 31 Mar). Checkboxes: 附加表格 (additional) / 修訂表格 (revised); sheet X of Y. 1. Payer 付款人: (a) 僱主檔案號碼 employer file no. [e.g. "6B1 77021233"] or BR/HKID; (b) name; (c) address. 2. Recipient if partnership/unincorporated body: name + BR no. 3.(a) if sole proprietorship/uncertain: name + BR no. (b) if INDIVIDUAL: (i) title + English full name (SURNAME, GIVEN) + Chinese name; (ii) HKID (req); (iii) sex M/F (req); (iv) marital status 1/2; (v) spouse name + HKID/passport if married. 4. Recipient address + phone. 5. 服務身分 capacity: 判商(sub-contractor)/代理人/作家/顧問/教練/導師/其他 (delete N/A). 6. 服務期間 period of service: from–to (DD MM YYYY). ← see rule below. 7. Remuneration by category (HK$, no cents): 承判金 sub-contract / 佣金 commission / 作家投稿費 / 演藝費 / 版權專利費 / 顧問費 / 自由職業者服務費 (instructor/coach/photographer) / 其他; + 總額 total. 8. Tax withheld? 0=no / 1=yes (+ amount). (mandatory) 9. Remarks. Signature block: 公司蓋印處 (company chop area) + 簽署 signature + 姓名 name + 職位 title + 日期 date. Footer: provide a completed copy to the recipient.

服務期間 (field 6) rule — answer to owner

Must fall WITHIN the year of assessment = 1 April [Y-1] → 31 March [Y] (the form is "the 1-year period ending 31 March [Y]"). Filled example: 03/09/2024 → 30/03/2025, inside YA 2024/25. The "30 March" end is that engagement's actual end date — NOT a rule; there is no "must end 30 Mar" constraint. Build will VALIDATE from/to to [1 Apr, 31 Mar] of the selected YA. (Owner's "Mar 31 to Mar 30" ≈ the YA window; precisely 1 Apr–31 Mar.)

Data mapping (have vs capture)

HAVE: payer name/ZH/address/phone + brNumber (fetchSubsidiaryInfoServer('erl')); recipient name/address/phone (payee directory); YA (ir56m.ts yearOfAssessmentForIso); remuneration total (generateIR56MCandidatesServer). CAPTURE (not stored): recipient HKID, sex, marital status (+ spouse), capacity (field 5), per-category split (field 7), period of service (default first/last payment, owner edits), tax-withheld flag.

OWNER CONTEXT (2026-06-15) — STOP dismissing the Jeffero IR56M

Jeffero Chan is a director AND a bona-fide sub-contractor to ERL. Payments to him are 5050 Sub-Contractor Fees, backed by WOPCs (the contract: which project/service, paid for what). So IR56M IS legitimately in scope on the merits — do NOT keep warning "he's a director so probably N/A." Threshold depends on the capacity classification the owner picks (判商 sub-contractor $200k vs consultant/other-service $25k).

CORRECTION — in-app signing from Records is NOT supported today

Owner clarified: a WOPC closing/assigned director CANNOT log on → Records → WOPC tab → sign there; they only see a Request Signature button. Actual signing happens via the emailed magic-link page (/wopc/sign/[id]). So "director signs from the Records page" is GENUINELY NEW work — build it for IR56M (and it would benefit WOPC too). My earlier "already supported" was wrong.

T-047a DONE (2026-06-15) — Contacts stores IR56M recipient particulars

Owner asked to make the web app STORE the lacking fields (input → store → read back). Built on the payee record (Contacts → People → a person → Edit): HKID, Chinese full name, sex, marital status, spouse name, spouse HKID/passport. - lib/payeeDirectory.ts: PayeeTaxProfile + taxProfile on record; read in BOTH parsers; flat + WriteInput fields. - lib/payeeDirectory.server.ts: buildPayeeDocument writes taxProfile; update-merge + change-detect; server parser reads it. - components/people/PeopleApp.tsx: shared Ir56mParticularsFields (add + edit forms), PayeeRow + form-values + row-map + hydrate + both submit bodies + detail read-back. - API route is a passthrough (no per-field allowlist) so fields flow through. - VERIFIED: server write→read round-trip against real Firestore (throwaway payee, deleted after); tsc 0 errors; 0 NEW lint errors (line-intersection check). Sensitive PII (HKID) never logged. COMMITTED to nightly 56c6b257 (NOT main).

Signing — REUSE WOPC mechanics (paths exist; wire an IR56M doc-type)

All four WOPC signing paths are reusable (the magic-link sign page, email request, chop, manual upload); the NEW part is letting the director sign from the Records page: - Director registry lib/directors/registry.ts (DIRECTORS: jeffero-chan/JC, jake-ngai/JN) = the "closing director" picker. Cross-issuance rule: if payee is a director, the OTHER signs (WOPC-specific anti-self-sign on GL5050; for IR56M the signer is just ERL's authorized director — revisit). - State machine draft→assigned→sent→signed (lib/wopc/signingRequests/*). - (a) in-app draw /wopc/sign/[id]/draw; (b) email request via Resend lib/email/sendWopcSigningRequestEmail.ts + /api/wopc-signing/[id]/send; (c) chop /wopc/sign/[id]/chop (secured seal assets); (d) manual upload /wopc/sign/[id]/upload. - Records WOPC tab actions (RecordsApp.tsx ~2263): Request signature / Send / Sign / Withdraw / Upload signed; director inbox app/wopc/inbox. - Signed PDF → Drive "14c. WOPC" (lib/wopc/signing/drive.server.ts). IR56M → new "14d. …" folder. Layered "Filings" group exists: AccountingApp.tsx layerAssignments (tax = order 40); add ir56m-filing order 41.

Phase 3 — signing build plan (starting 2026-06-19; investigated the WOPC system)

Signature-block TEXT (姓名/職位/日期) is DONE (#5, 3bf3d718). Remaining = ink signature + chop on the filled PDF + the request/sign workflow. REUSE the WOPC signing system, don't duplicate it. Findings: - composeSignedWopcPdf (lib/wopc/signing/composition.server.ts) embeds the signature + 11 ERL chop assets via pdf-lib + has the void-stamp logic — but its PLACEMENT COORDINATES are WOPC-A4-specific (closing-signature line + chop box). IR56M needs its OWN coordinates (its 簽署 line + 公司蓋印處 box). So reuse the embed technique + chop assets + void logic; supply IR56M placement. - drive.server.ts files to hardcoded "14c. WOPC" via the accounting Drive SA + lazy-create — reuse the pattern, new target "14d. IR56M". - placement.ts (parse signature/chop placement) — generic, reuse as-is. - signingRequests/ state machine + types are WOPC-COUPLED (wopcRef + the WOPC doc's locks/void). Sub-phases: - 3a — DONE (lib/taxHK/ir56m/sign.server.ts)composeSignedIR56MPdf (signature scaled into the IR56M signature box above the 簽署 line — render-verified — + optional chop via the reused generic composeChopOntoPdf + getChopImageBytes) + uploadSignedIR56MToDrive (→ "14d. IR56M", mirrors the WOPC 14c Drive pattern). tsc 0. Chop placement is director-picked (calibrated when 3c lands). - 3b — IR56M signing-REQUEST model. DECISION (owner): generalize the WOPC signingRequest to a doc-type-agnostic model (docType:'wopc'|'ir56m', docRef) — cleaner, but touches the LIVE audit- critical WOPC system (risk) — vs a PARALLEL ir56mSigningRequest reusing the lower-level utilities — safer, some duplication. RECOMMEND parallel-but-shared-utilities. - 3c — the 4 signing paths (in-app draw / emailed magic-link / chop / manual upload) + the NEW "sign from Records" surface (directors only get Request-Signature today — new even for WOPC). - Cross-issuance: JC's IR56M signed by the OTHER director (JN). Relates to T-050 (WOPC cross-issuance bug) — apply the same rule, do NOT duplicate T-050's fix. NO OVERLAP: signing is wholly within T-047 ("…→ sign"); T-050 is a separate WOPC bug; the WOPC signing system is REUSED, not duplicated.

Proposed sub-tasks

  • T-047a — IR56M filing data model (Firestore ir56mFilings/{id}: YA, payer snapshot, per-payee particulars incl. captured HKID/sex/marital, capacity, period, category split, withheld, status) + pure builder/validators (period-within-YA, totals).
  • T-047b — Filings tab UI: pick YA → seed payees from candidate scan → fill/capture missing fields → review + confirm.
  • T-047c — IR56M PDF render (pdf-lib over the IRD form, or AcroForm fill) + preview.
  • T-047d — Signing: reuse WOPC request/sign mechanics for an IR56M doc-type (closing-director pick, in-app sign on Records, email request, chop, manual upload) → file to Drive 14d.
  • T-047e — Records "IR56M" surface so the assigned director can find + sign it.

Reality check (carry from T-045)

Only >$25k candidate is JC (director) at $85,300 of 5050 subcontractor fees — payments to a director generally are NOT IR56M. ERL likely has NOTHING to file this YA; feature would be ready for when a real unincorporated payee crosses the threshold. Confirm before/while building.

Log

  • 2026-06-15 created (owner). Studied IR56M.pdf + IR56M_Filled.pdf + IRD form; mapped WOPC signing (reusable) + Layered Filings group (exists). Answered 服務期間 rule. Awaiting go-ahead.
  • 2026-06-19 phases 1+2 built (c802b81f / 2594d460 / 05f376a0 / 243e94d0). Owner reviewed the rendered form vs the official specimen (ir56m_completion_e.pdf) + notes (ir6036c_e.pdf).
  • 2026-06-19 RENDERING CORRECTIONS (nightly 30fb0cce) — VERIFIED the title/capacity checkboxes STRIKE-when-checked by rendering the template (a "delete the inapplicable" form): 2d title strikes complement (Mr left, Ms/Miss struck); 2f capacity strikes complement (all 6 roles struck → 其他 + "Sub-contractor"); 2e English name surname+SPACE+given, no comma; 2g period DDMMYYYY no separators; 2a Sheet No. alphabetical from 900001 (note 4); 2h whole YA total → Type 1 承判金 (5050 payees); 2i tax-withheld = 0. + 5 engine tests (ir56mFields.test.ts).
  • 2026-06-19 amount right-align (nightly 0515eaa4): field-7/8 amount boxes are comb (MaxLen 9); mupdf ignores /Q, so left-pad with spaces to right-align. No "$" rendered (bare digits).
  • 2026-06-19 2b/2c via T-070 (nightly e905deeb): Subsidiary tab stores the employer file no. (renders 僱主檔案號碼) + the registered address incl. region "Hong Kong" (so the address ends ", Hong Kong"). Owner to populate ERL's employer file no. + region in Contacts → Subsidiary.
  • 2026-06-19 RENDERING CORRECTIONS round 2 (nightly 3bf3d718, owner review #2): #1 Sheet No. — the form PRE-PRINTS the leading "9"; the field holds the 5 digits after it (MaxLen 5) → fill the suffix 900001→"00001" (was sending 6 → truncated). #2 English name — RESTORE the comma "CHAN, Jeffero" (reverses round-1's no-comma 2e; the form's 3(b)(i) text + bottom example both use a comma). #3 Period of service → default to the full YA the period selector resolves to (1 Apr–31 Mar), not first/last payment dates. #4 Payer block (name/address/employer-file-no) now READ-ONLY display from the stored subsidiary info (Contacts → Subsidiary); no longer inline-editable here; the seed reads employerFileSection/Number. #5 Signature block — new "Signing director" selector fills 姓名 + 職位 (Director); 日期 = today.
  • REMAINING on T-047: phase 3 = signing (reuse WOPC + "sign from Records" → Drive 14d).

2026-06-22 — phase 3b foundation: parallel ir56mSigningRequest store

  • Attestation (EOP Local Assistance): read AGENTS.md; tracking T-047, board kept current.
  • Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)
  • Built (tsc-clean): lib/taxHK/ir56m/signingRequests/types.ts + repo.server.ts — the IR56M signing-request store, on a SEPARATE aote-system/ir56mSigningRequests collection (never touches the live wopcSigningRequests). Keyed on (endingYear, payeeId) = the recipient-filing identity; same draft→assigned→sent→signed/rejected/withdrawn(/voided) state machine; race-safe findOrInsertActiveRequest, transactional guarded applyTransition, events audit subcollection. The generic signing types (status / source / chop / event) are reused from lib/wopc/signingRequests/types.
  • Why parallel, not generalize: generalizing the WOPC signingRequest to a doc-type-agnostic model would touch the live, audit-critical WOPC store (risk); a parallel collection that shares the lower-level utilities is the safer route the task recommended.
  • Remaining in phase 3:
  • 3b operations (operations.server.ts): state-machine mutations (createOrAssign / send / record- signature / reject / withdraw / void) wiring the store to the 3a primitives (sign.server.ts composeSignedIR56MPdf + uploadSignedIR56MToDrive) and flipping the recipient IR56MRecipientStatus (draft→confirmed→signed→filed). Cross-issuance: a director's own IR56M is signed by the OTHER director (mirror WOPC; relates to T-050 — reuse, don't duplicate).
  • 3c paths + UI: the 4 signing paths (in-app draw / emailed magic-link /ir56m/sign/[id] / chop / manual upload) reusing the WOPC sign-page mechanics, + the "sign from Records" surface, + Resend email,
    • API routes.
  • Needs p-eop preview deployment testing before prod — pending the NAS deploy recovery.

Phase 4 — Records-page IR56M tab + close-gated freeze / void (design, owner-agreed 2026-06-22)

  • Attestation (EOP Local Assistance): read AGENTS.md; tracking T-047, board kept current.
  • Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)

Goal: an IR56M tab on the Records page mirroring the WOPC tab (store + display + sign), reusing the WOPC signing mechanism, with the sign/seal pipeline gated on the YA being fully closed.

Gate (close-aware): - "YA fully closed" = all months in the YA window (1 Apr–31 Mar) are closed (per-month close, lib/periodClose). Not fully closed → preview-only: the IR56M tab (Records + Accounting → Filings) renders the filled PDF, but Sign/Seal is disabled. - Reopen flips back to preview-only. An in-flight request already sent is paused (Sign disabled) until re-close — then resumes if the recipient's total is unchanged, or voids + re-issues if it changed.

Freeze-on-close (set in stone): when the YA fully closes, snapshot each reportable recipient's WHOLE assembled IR56MFormData (declared total → its category + identity particulars + owner-entered capacity/ period/withheld/remarks) to an immutable record so the rendered IR56M can't drift. - Scope: all reportable recipients over threshold (not just related parties). - Declared total = GL 5050 sub-contractor fees posted to that recipient within the YA (closed data / the existing candidate scan). - Incomplete owner-fields at close → snapshot-as-is + flag incomplete (don't block the freeze).

Re-adjust / VOID (per-recipient): on YA re-close, compare each recipient's new declared total vs its prior snapshot: - Unchanged → no-op (existing IR56M stands). - Changed → void the OLD per-recipient IR56M like a voided WOPC: - unsigned: render the old-snapshot form + VOID chop → auto-download (re-closer's browser) → save to Drive (loc TBD) → show hidden in the Records IR56M tab → re-initiate sign/seal on the new snapshot. - signed: apply the VOID chop to the actual signed PDF → auto-download → save to Drive (TBD) → hidden in Records → re-initiate. - Dropped under threshold → void the old, don't re-issue. Newly over threshold → fresh IR56M. - Re-close must stay a user action (the auto-download needs a live browser).

Signer (reuse WOPC mechanics): cross-issuance — a director recipient's IR56M is signed by the other director; a non-director recipient → a director the owner picks at send-time. Paths: in-app draw / emailed magic-link (/ir56m/sign/[id]) / chop / manual upload + the new "sign from Records" surface.

Signed storage + display: signed IR56M → Drive PDF ("14d. IR56M", structure TBD); Accounting → Filings previews it; Records IR56M tab displays it (voided ones shown hidden).

Distribution (Resend): on sign+seal, schedule a send 3 days later (a regret window) to all personnel including the sub-contractor recipient (the IRD-required copy). If the IR56M voids within those 3 days, cancel the pending send. A voided IR56M is emailed to internal personnel only (directors + accountant/bookkeeper), never the recipient (the info is wrong).

Deferred (to discuss): the snapshot Firestore structure + the Drive locations (signed vs hidden voided). Depends on a YA-window-close view; the full FY→YA unification is now its own task T-085. Needs p-eop preview to test before prod.

Evidence — owner, 2026-06-22 (verbatim, key decisions):

"instead of closing a FY, maybe it's better to re-design and unify the web app to close a YA instead"

"if no IR56M has been signed, an IR56M should be rendered with the old information on it with a VOID chop … an auto-download should be triggered … while if an IR56M has been signed … all signed and sealed IR56M with the old information … with a VOID chop"

"we don't send a voided IR56M to the sub-contractor recipient as the information is incorrect … send VOID version to related personnel (not the ultimate recipient), and when a IR56M is signed, send 3 days after it's signed and sealed (so that we could have time to regret if we made any mistakes)"

Phase 4 — decisions LOCKED + build started (2026-06-22)

  • Attestation (EOP Local Assistance): read AGENTS.md; recording the two deferred phase-4 design decisions with the owner's approval before building.
  • Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)
  • Decision 1 — Declared-total FREEZE storage: at YA-close, stamp each recipient on the existing ir56mFilings doc (keyed by endingYear) with declaredTotalFrozen + frozenAt + an incomplete flag — no new collection/structure. (Owner confirmed: "Re: 1 = Freeze".)
  • Decision 2 — Drive home for signed IR56M: a NEW top-level 70. Tax & Compliance, keyed by Year of Assessment (70. Tax & Compliance/{YA}/IR56M/; voided → …/IR56M/_voided/, internal-only). NOT under 14. Expenses Records — IR56M is an employer's statutory return, not an expense (the app already groups it under "Tax Filings" next to Profits Tax). Also the future home for BIR56A / IR56B / profits tax. (Owner asked "is it more about employment?", then chose the 70. Tax & Compliance top-level.)
  • Bug found + fixed in passing: the planned path sign.server.ts:83 '14d. IR56M' collided with the live '14d. Service Invoices' (T-069). Moving IR56M out of the 14-series removes the collision.
  • Build order (each increment committed, tsc-clean, lockstep): (1) Drive routing → 70. Tax & Compliancethis commit; (2) close-gate + freeze; (3) Records IR56M tab; (4) signing pipeline (request→sign→chop→upload + sign-from-Records); (5) void-on-readjust + 3-day Resend.
  • Increment 1 (done): lib/taxHK/ir56m/sign.server.ts now files to 70. Tax & Compliance/{YA}/IR56M[/_voided] (lazy-created, app-owned section); uploadSignedIR56MToDrive re-keyed signYearyearOfAssessment + a voided? flag; the 14d. Service Invoices collision is resolved. No external callers (signature change safe).
  • Increment 2 (done): close-gate + declared-total freeze mechanism — NEW files only, no edits to the shared periodClose / API yet (low blast radius given the heavy parallel cloud work). closeGate.ts (pure YA month math — the salaries-tax YA is the FIXED 1 Apr–31 Mar year, deliberately NOT the configurable profits-tax basis period; requiredCloseMonths clamps to the incorporation month so pre-incorp months aren't demanded); closeGate.server.ts isYearOfAssessmentClosed; freeze.server.ts freezeIR56MDeclaredTotalsServer (write-once per recipient, idempotent, sources the total from the candidate scan = the transaction-derived figure that mustn't drift); filing.ts gains IR56MDeclaredTotalFreeze + frozen? + pure isRecipientParticularsIncomplete. vitest 10/10, tsc clean for these files (the lone repo error is croner in the merged T-084 code + a stale local node_modules — not this change). Next (2b) = wiring: hook the freeze into the period-close handler (pages/api/accounting/periods/index.ts, next to maybeFreezeFiscalYear), expose yaClosed on the filing API GET, and protect the frozen block on the save path so the client can't overwrite it.
  • Increment 2b (done) — wiring: the freeze now fires automatically from the period-close handler (pages/api/accounting/periods/index.ts, beside maybeFreezeFiscalYear, best-effort — a freeze failure never fails the close), keyed to the just-closed month's YA via the new pure yearOfAssessmentEndingYear. The filing API GET now returns yaClosed (the tab's sign/seal gate; preview stays open). saveIR56MFilingServer now guards the frozen block — set-in-stone: a client save can't alter or drop a frozen total, though a NEW freeze still passes through. vitest 12/12, tsc clean for these files, eslint clean. Blast radius: closing a month now also triggers an idempotent IR56M freeze attempt — additive, but agents on periodClose should know. Phase-4 close-gate + freeze is COMPLETE. Next = increment #3: the Records-page IR56M tab (display filings + frozen totals + preview, WOPC-tab pattern).
  • Increment 3 (done) — Records IR56M tab: new components/records/IR56MFilingsTab.tsx (a SEPARATE component registered with a 1-line tab object in RecordsApp.tsx's canSeeErl array — kept the footprint in that 3k-line, cloud-edited shared file tiny) + new pages/api/records/ir56m-filings.ts (lists saved filings + a per-filing yaClosed). The tab lists saved filings by YA, expands to recipients showing the declared total (the FROZEN figure once the YA closed, else the live form total) with frozen / incomplete tags + status, a per-recipient PDF preview (reuses /api/accounting/ir56m/fill), and the yaClosed gate as a lock tag. tsc clean for my files; new files eslint-clean (next lint is broken locally — ESLint-version mismatch; RecordsApp's pre-existing lint debt is untouched + unrelated). Browser verification is owner-on-deploy (the Records page is auth-gated). Next = #4: the signing pipeline (request → sign → chop → upload to 70. Tax & Compliance, + sign-from-Records), gated on yaClosed.
  • Increment 4a (done) — signing operations layer: new lib/taxHK/ir56m/signingRequests/operations.server.ts — the state-machine orchestration mirroring WOPC (createOrGetRequest / assignDirector / sendForSigning / recordSignature / recordRejection / recordWithdrawal / recordVoided), keyed (endingYear, payeeId) on the separate ir56mSigningRequests collection, with no WOPC-style doc-mirror (IR56M signed/voided state lives only on the request). Each transition validates → repo patch (terminal-overwrite-guarded) → audit event. T-089 conflict cleared (an Explore confirmed the reused WOPC primitives — composeChopOntoPdf, getChopImageBytes, the signing types — are stable + untouched by the restructure). Director email deferred (request shows in the inbox once sent). tsc + eslint clean. Remaining #4: (4b) API endpoints /api/ir56m-signing/* + the sign action (compose → chop → uploadSignedIR56MToDrive) + client helpers; (4c) magic-link /ir56m/sign/[id]
  • the Resend email; (4d) the Records-tab signing actions (Request / Withdraw / Upload / Void).
  • **Correction (2026-06-22) — Drive location fixed (supersedes the 70. Tax & Compliance path in Decision 2
  • Increment 1 above). The owner flagged that 70. Tax & Compliance doesn't exist — I'd invented it. Inspected the live ERL drive: 70 is actually 70. Talent & HR Strategy, and the company's employer return (BIR56A/IR56B) + profits tax (BIR51) already live in 90. Governmental & Compliance / Inland Revenue Department / Tax Department. So IR56M files there — owner-confirmed structure …/Tax Department/{YA}/IR56M/ (signed) + …/{YA}/IR56M/_voided/ (voided, internal-only), named ERL_IRD_IR56M_{recipient}_{YYYYMMDD}.pdf to match the existing ERL_IRD_<form>_<date> convention. sign.server.ts corrected (requires the 3 human-curated levels, lazy-creates {YA}/IR56M/_voided, + added buildIR56MFilename). The old 70. Tax & Compliance path never filed anything** (uploadSignedIR56MToDrive had no caller yet), so no docs were misfiled.
  • Source (edit): EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e
  • Evidence — owner, 2026-06-22 (verbatim): > "can you look thru the folder structure of my current Establish Records Limited drive and find the right > place for the uploading of the IR56M? Cos I don't think that there's 70. Tax & Compliance on my Drive?"
  • Increment 4b (done) — requester-side signing endpoints + client: new pages/api/ir56m-signing/ endpoints — index (create-or-get, keyed endingYear+payeeId), [requestId]/assign, /send, /withdraw — each thin over the #4a ops layer with auth. New signingRequests/apiAuth.ts reuses the GENERIC requireSessionActor / requireMethod from the WOPC apiAuth + adds loadAuthorizedIR56MRequest (loads via the IR56M repo) + getRequestIdParam. New signingRequests/client.ts fetch wrappers (createOrGetIR56MSigningRequest / assignIR56MDirector / sendIR56MForSigning / withdrawIR56MSigningRequest). /send transitions to sent (the director email lands in #4c). tsc + eslint clean. Next (4c) — director-side: inbox + the sign endpoint (compose → chop → upload via the corrected 90/IRD/Tax Department/{YA}/IR56M path) + the magic-link /ir56m/sign/[id] + the Resend email; then (4d) the Records-tab signing actions.
  • Increment 4c (email, done) — IR56M signing email as a SSOT shared template (owner-requested): new lib/email/templates/IR56MSigningRequestEmail.tsx (React-email component + ir56mSigningRequestSubject) + lib/email/sendIR56MSigningRequestEmail.ts (sends to the assigned signing director's contactEmail — NOT the payee — with the magic link /ir56m/sign/[requestId] + recipient/YA/employer in the card; graceful degradation on no-email / no-Resend). Per the owner's ask, the SAME component is the single source of truth for BOTH the sender AND the Tools-page Email Templates preview — registered as one entry (ir56m-signing-request) in pages/api/tools/email-preview.ts, so the live email + the preview can't diverge. sendForSigning (ops layer) now dispatches it (best-effort, lazy-import; never unwinds the sent transition). tsc + eslint clean; owner can preview it on Tools → Email Templates. Remaining #4c: the sign endpoint (compose → chop → upload) + signature stash + director inbox + the magic-link page; then #4d the Records-tab signing actions.
  • Increment 4c (signing backend, done) — sign + reject + inbox endpoints: pages/api/ir56m-signing/[requestId]/sign.ts — the assigned director's sign action: regenerates the FILLED PDF from the SAVED filing (stamping the signer block with the signing director — never client form input), overlays the signature image + company chop via composeSignedIR56MPdf, uploads to 90/IRD/Tax Department/{YA}/IR56M (named via buildIR56MFilename), then recordSignature. Image sources (drawn / saved-upload) fill+compose; a scanned-upload PDF (admin upload-on-behalf only) is archived as-is. [requestId]/reject.ts (assigned director declines) + inbox.ts (the director's sent requests — queried by director, then filtered/sorted in memory to avoid a new composite Firestore index). tsc + eslint clean. Remaining #4c: the magic-link /ir56m/sign/[id] page (signature canvas + chop picker) + in-app signing; then #4d wires the Records-tab actions (Request / Withdraw / Sign / Void).
  • Increment 4c (signing UI — route 1/5, done): the magic-link wizard ENTRY (owner chose full WOPC-style parity). app/ir56m/sign/[requestId]/page.tsx (server: NextAuth gate + load request + subsidiary / identity guard → props; best-effort recipient + YA from the saved filing) + client.tsx (step 1: react-pdf preview of the filled IR56M + Reject-confirm modal + Sign → ./method). New GET /api/ir56m-signing/[id]/preview-filled — fills from the saved filing via a shared lib/taxHK/ir56m/signFill.server.ts resolver, so the preview == exactly what gets signed; + rejectIR56MSigningRequest client helper. tsc + eslint clean; owner verifies on the deploy (auth-gated; Sign 404s into ./method until that route lands — incremental). Remaining wizard routes (mirror WOPC): /method (draw / editor / upload) → /draw + /editor (capture) → /chop (place the seal; + a chop-image endpoint) → /preview (final compose via a preview-signed endpoint + submit). Then #4d the Records-tab actions. (Tiny follow-up: point the sign endpoint at signFill.server.ts to retire the duplicated assembly.)
  • Increment 4c (signing UI — route 2/5 + scaffolding, done): signFlow.client.ts (sessionStorage + pointer/orientation hooks; no placement — IR56M's signature box is fixed), a shared guard.server.ts (inner-route auth + assigned-director/admin guard, used by all wizard routes except the lenient entry), and /method (page + client). /method offers Sign on screen (draw → on every device, since the box is fixed) + Upload a signed copy (scan/photo or own-editor PDF → scanned-upload, straight to preview) + a download-unsigned link. WOPC's "sign on the document" editor is intentionally omitted — IR56M has a fixed signature box, so free-position signing doesn't apply (noted so it's not mistaken for a gap). tsc + eslint clean. Remaining wizard: /draw (canvas) → /chop (place the seal; + a chop-image endpoint) → /preview (final compose; + a preview-signed endpoint) + submit. Then #4d the Records-tab actions.
  • Increment 4c (signing UI — route 3/5, done): /draw (page + client) — the full-screen signature canvas, mirroring WOPC: reuses the generic FreehandSignaturePad (perfect-freehand), a draggable baseline guide, a portrait→rotate prompt, undo/clear, and Done → bounding-box-crop → stash (source: 'drawn') → /chop (the cropped ink is later scaled into IR56M's fixed signature box by composeSignedIR56MPdf). tsc + eslint clean. Remaining: /chop (place the company seal on the rendered page; + a chop-image endpoint) → /preview (final compose; + a preview-signed endpoint) + submit. Then #4d.
  • Increment 4c (signing UI — route 4/5, done): /chop (page + client) — places the company seal, mirroring WOPC's chop step. Two new endpoints back it: GET /api/ir56m-signing/[id]/chop-image?variant= (proxies the secured getChopImageBytes so the private chop asset never sits in public URL space + reads are auth-gated) and POST /api/ir56m-signing/[id]/preview-signed (regenerates the FILLED PDF from the saved filing, composes the signature + optional chop via composeSignedIR56MPdf, returns the PDF, persists NOTHING; a scanned-upload PDF is returned as-is). The client renders the signed-no-chop PDF, picks a random ERL chop variant (1–11) + a random ±15–30° tilt to mimic a hand-applied seal, then the chop follows the cursor (fine pointer) / taps to place (coarse), capturing { xPct, yPct, rotationDeg, variant, pageIndex } in normalised page coords. Chop diameter is pinned to the server's 20 mm/57 pt so the on-screen size == the stamped size. Done → writeStoredChop/preview. tsc (MY=0; only the known stale-node_modules croner error remains) + eslint clean. Remaining wizard: /preview (route 5/5 — final compose via preview-signed with the stashed signature+chop, then submit → the sign endpoint). Then #4d the Records-tab actions (Request / Withdraw / Sign / Void).
  • Increment 4c (signing UI — route 5/5, done → magic-link wizard COMPLETE): /preview (page + client), mirroring WOPC's final preview. Server page reuses requireIR56MSignAccess + loads best-effort recipient name + YA from the saved filing (same as the entry page). Client reads the stashed signature (+ chop, if the draw path placed one) and POSTs /api/ir56m-signing/[id]/preview-signed to render the composed PDF via react-pdf, then Submit POSTs the SAME body to /api/ir56m-signing/[id]/sign (re-runs the identical composition → guaranteed to match what's shown), clears the stash, and routes to /records?tab=ir56m. Empty session bounces back to /method. No placement in the body — IR56M's signature box is fixed (the one structural divergence from WOPC, by design). tsc (MY=0) + eslint clean; /records?tab=ir56m deep-link confirmed (RecordsApp honours ?tab= on mount). The 5-route magic-link signing wizard (entry → method → draw → chop → preview) is now end-to-end for the two happy paths: (a) draw-on-screen → place chop → submit, and (b) upload a signed PDF → submit (archived as-is). Known gap before #4d: the "upload a scan/photo" path sends a scanned-upload image straight to /preview (skipping chop), but the sign endpoint's image branch still requires a chop → it would 400 at submit. A photo of an already-signed page should be archived as a single-page PDF (it's the finished document, not a bare signature to overlay) — fixed next in #4c-5b before #4d. Remaining: #4c-5b (scanned-upload image → archive-as-PDF) then #4d (Records-tab actions: Request / Withdraw / Sign / Void).
  • Increment 4c-5b (done) — scanned-upload image archives as a PDF (closes the photo-path gap above): the upload path's two artefacts are now handled symmetrically. New imageToSinglePagePdf in sign.server.ts wraps a PNG/JPEG into a single-page A4 PDF (fit, aspect-preserved, centred on white). Both the /sign and /preview-signed endpoints now branch on source === 'scanned-upload' (the user supplied the FINISHED, hand-signed document) → archive it: a PDF verbatim, a photo/scan wrapped to PDF — no form regeneration, no chop. The overlay branch (a drawn signature image stamped into the fixed box + chop) is now reached only for drawn/saved-upload, and rejects a stray PDF there with a clear 400. So "upload a scan / photo of a signed page" now produces a real filed PDF instead of 400-ing for a missing chop. tsc (MY=0) + eslint clean. The full magic-link wizard's three real paths now work end-to-end: draw+chop, upload-PDF, upload-photo. Remaining: #4d the Records-tab signing actions (Request / Withdraw / Sign / Void), + the tiny follow-up to point /sign at signFill.server.ts (retire its duplicated form assembly).
  • Increment 4d-1 (done) — Records → IR56M tab now shows per-recipient signing status (foundation for the actions): new GET /api/ir56m-signing/lookup (keyed by endingYear + payeeId, mirroring the WOPC lookup): ?payeeId={ request }, ?payeeIds=a,b,c{ requests: { [payeeId]: wire|null } }, returning the LATEST request (incl. terminal states, so the badge stays accurate after a request closes) + a server-computed currentUserCanSign (caller is the assigned director AND the form still awaits a signature). New client helper lookupIR56MSigningRequests(endingYear, payeeIds) + currentUserCanSign? on IR56MSigningRequestWire. IR56MFilingsTab batch-loads each YA's statuses after the filings load (keyed ${endingYear}:${payeeId}, best-effort — the archive never blocks on it) and renders a Signing column per recipient (Draft / Assigned / Sent for signing / Signed / Rejected / Withdrawn / Voided, with the reject/withdraw reason on hover). Read-only for now. tsc (MY=0) + eslint clean. Next #4d-2: the per-recipient action menu — Request (director-picker modal → assign + send + email) / Withdraw / Sign-now deep-link to the wizard; then #4d-3 Void (endpoint + modal).
  • Increment 4d-2 (done) — per-recipient signing action menu (mirrors the WOPC Records-tab kebab): new components/records/IR56MSigningRequestModal.tsx (two-mode, mirroring WopcSigningRequestModal): request = a signing-director picker (the two ERL directors from the registry) + a read-only email preview, which on send does create-or-get → assign → sendIR56MForSigning (server dispatches the magic-link email); withdraw = optional reason → withdrawIR56MSigningRequest. The recipient row's Actions cell now carries a kebab Dropdown (beside the Preview button) whose items are state-gated, same as WOPC: Request signing (no active/signed request and the YA is closed — frozen; preview-only before that) · Sign now (caller is the assigned director, signature pending → deep-link /ir56m/sign/[id]) · Send for signing (assigned, not yet sent) · Upload a signed copy (sent → /ir56m/sign/[id]/method) · Withdraw request (active). Successful actions patch the badge in place (no reload) via applySigningChange. Uses the project's ItemType (antd/es/menu/interface) menu typing, not MenuProps. tsc (MY=0) + eslint clean. Next #4d-3: Void (endpoint + client helper + confirm modal) for a signed IR56M → internal-only _voided Drive subfolder; then the tiny /signsignFill.server.ts follow-up.
  • Increment 4d-3 (done) — Void a signed IR56M (completes #4d; all four Records-tab actions live): new POST /api/ir56m-signing/[id]/void, mirroring the WOPC void: downloads the signed PDF, stamps a random VOID impression over it (reuses overlayStampOntoPdf + getStampVariantImageBytes('void', …) + pickVoidStampPlacement + VOID_STAMP_WIDTH_PT — the shared general stamp assets), files that voided copy into the internal …/Tax Department/{YA}/IR56M/_voided folder, trashes the clean signed copy (Drive trash → ~30-day recovery), then recordVoided (request → voided). Returns the clean (pre-void) PDF as base64 so the operator gets a local copy. New client helper voidIR56MSigningRequest + IR56MVoidResult; the tab adds a Void signed IR56M kebab item (status signed) → a danger confirm modal → on confirm: void, auto-download the clean copy, re-fetch the badge (→ Voided). No email here — the owner-specified internal-only VOID notice + the 3-day-regret send-on-sign are the separate distribution increment (#5); #4d-3 is Drive + status only (no outward send), which is unambiguous and owner-specified, so it was safe to build without a checkpoint. tsc (MY=0) + eslint clean. Phase-4 #4d is COMPLETE (status display + Request / Send / Sign / Upload / Withdraw / Void). Remaining on T-047: #5 the distribution flow (3-day-regret send-to-all on sign + internal-only VOID notice + void-on-YA-readjust) — needs an owner checkpoint on the outward-email behaviour before building; and the tiny /signsignFill.server.ts dedup follow-up.

Phase 4 #5 — distribution REDESIGNED to interactive per-sign config (owner, 2026-06-23)

  • Attestation (EOP Local Assistance): read AGENTS.md; recording the #5 redesign + the owner's approval/evidence before building, board kept current (tracking T-047).
  • Source: EOP Local Assistance · local Claude Code session
  • What changed: the original #5 ("auto-send to all 3 days after sign; void notice internal-only") is superseded by an interactive, per-sign distribution config the signing director sets at chop/sign time, with the choices stored + editable later. Asked who should receive the completed + void emails; the owner redesigned the whole flow. Decisions LOCKED:
  • Grace period is the director's choice, per-sign: at sign time (right after chopping) prompt the signing director to either send immediately or set an X-day grace/regret period before the "completed IR56M" email goes out. Default: enabled with the regret window.
  • Recipient picker shows REAL people, not roles: list actual people in the web app with their role — e.g. "Jeffero Chan — Director, Jake Ngai — Director, Alison Tang — Accountant" — multi-select. Default selection (completed email): directors + the recipient + accountant + bookkeeper (degrade gracefully — a defaulted role with no real person yet is skipped, never blocks). This exposed the RBAC roster gap → T-094 (owner-requested; the picker sources from whatever real users exist until T-094 lands).
  • Void notice is ALSO director-configured at sign time, default directors-only: when chopping, the director also sets who'd receive the internal VOID notice if it's ever voided. Default: directors only. Never the recipient (the info is wrong).
  • Hard rule — never send to no one: the director may hand-pick any people who exist in the web app, but the send list can't be empty — it must include at least the signing director himself.
  • Settings are stored + editable on BOTH tabs: once set (incl. the grace period), persist them and allow editing from the IR56M tab on the Accounting page AND the Records page.
  • Build plan (#5, each increment tsc-clean + lockstep): (a) distribution data model + the two SSOT emails (completed / voided) + a people-roster accessor (degrades to directors+recipient today); (b) the post-chop distribution-config step in the wizard + persist on sign; (c) the queue + cron drain (send when the grace window elapses) + cancel-on-void; (d) editable settings on the Accounting + Records IR56M tabs; (e — separate) void-on-YA-readjust. Mechanism: a Firestore ir56mDistributions queue drained by a new /api/cron/ir56m-distributions job added to the in-app scheduler (lib/cron/in-app-scheduler.ts), matching the existing cron pattern.
  • Evidence — owner, 2026-06-23 (verbatim):

    "up on signing and sealing the IR56M, prompt the user and let the user decide whether to create a x day grace period at all, or send immediately, while also give the option for user to choose which party for the email to be sent to as well while listing out not just the role name (like, Directors/recipient/super admin/ admin etc...) but the actual information of those people (like, Jeffero Chan - Director, Jake Ngai - Director, Alison Tang - Accountant). But for the time being, let the user decides while set the prompt to default on directors + recipient + accountant + bookkeeper first."

"Let the sign and seal director design upon him finishing the chopping, while default on directors only. While also for both the previous and this question: also allow director to manually pick people exist on the web app to send to, but don't allow the director to send to no one. It has to be sent to at least the closing director himself. If the signing director chose for it to have a grace period, have these settings stored and allow the changing of it on the IR56M tab on the accounting page AND the records page" - Increment 5a-1 (done) — distribution data model + store: new lib/taxHK/ir56m/distribution/types.ts (IR56MDistribution — one per signed IR56M: graceMode immediate/grace + graceDays + immutable signedAt + derived sendAfter; completedRecipients + voidRecipients as denormalized {kind,refId,name,role,email} snapshots so a stored config never depends on the evolving RBAC roster T-094; completed-send lifecycle pending→sent|cancelled; voidNoticeSentAt; + the never-empty recipients invariant assertNonEmptyRecipients, the owner's "at least the signing director" rule) and store.server.ts (Firestore CRUD on aote-system/ ir56mDistributions, doc id == signing-request id so create is idempotent + 1:1: createDistribution, getDistribution, listDuePendingDistributions [status==pending then sendAfter<=now filtered in memory — no composite index], markDistributionSent, cancelDistribution [no-op unless pending], markVoidNoticeSent, updateDistributionSettings [recomputes sendAfter from the immutable signedAt; pending-only]). tsc (MY=0) + eslint clean. Next #5a-2: the people-roster accessor (directors + recipient + aote-system/users, degrading gracefully; defaults = directors+recipient+accountant+bookkeeper / directors-only) + the two SSOT emails (completed / voided). Then #5b config step, #5c cron, #5d editable tabs. - Increment 5a-2 (done) — people-roster accessor + the two SSOT emails: distribution/recipients.server.ts resolveIR56MAddressablePeople resolves the picker's people from three sources (directors registry → both lists, default-checked, signing director locked; the recipient from the payee directory → COMPLETED only, never void, skipped if no email; aote-system/users → accountant/bookkeeper default-checked in COMPLETED, others addressable-unchecked), de-duped by email, degrading gracefully (a missing source never blocks) — + defaultCompletedRecipients (directors+recipient+accountant+bookkeeper) / defaultVoidRecipients (directors only). The roster is thin until T-094 fills the RBAC users. Two SSOT emails (template + subject imported by BOTH the sender AND the Tools email-preview, registered ir56m-completed + ir56m-voided): IR56MCompletedEmail (signed copy, sent to all configured recipients incl. the sub-contractor — PDF attached since the recipient isn't a web-app user) + IR56MVoidedNoticeEmail (internal-only VOID notice, VOID PDF attached, never to the recipient). Senders sendIR56MCompletedEmail / sendIR56MVoidedNoticeEmail send individually (no cross-exposure of addresses) + return per-recipient results for the store. Extended the shared email/client.ts sendEmail with an optional attachments passthrough (additive). tsc (MY=0) + eslint clean. Next #5b: the post-chop distribution-config step in the wizard (grace toggle + people multi-select w/ defaults + the never-empty/locked-signer rule) → persist on sign. Then #5c cron drain + cancel-on-void, #5d editable on the two tabs. - Increment 5b (done) — sign-time distribution config (folded into /preview, covers ALL sign paths): two endpoints — GET …/distribution-roster (addressable people + the owner's default selections) and POST …/distribution (creates the record post-sign; enforces server-side that both lists are non-empty and include the signing director). New distribution/client.ts (fetch/create helpers + recipientsFromValue + defaultConfigValue). New reusable components/ir56m/IR56MDistributionConfig.tsx — grace toggle (send-now vs hold N days, default 3) + two people checklists (completed / void) with the defaults pre-checked, the signing director locked in both, and the recipient excluded from the void list (internal-only). /preview now fetches the roster on mount, renders the config below the signed preview, and the submit POSTs /sign then /distribution (best-effort but surfaced — the signed doc is already filed; settings are editable on the tab if the create fails). Folding it into /preview (not a separate /distribute route) means it uniformly covers draw+chop AND the scanned-upload path, which both land there. The config component is reused for #5d. tsc (MY=0) + eslint clean (typed the antd handler params; RadioChangeEvent from antd/es/radio, CheckboxChangeEvent from antd/es/checkbox). Next #5c: the cron drain (/api/cron/ir56m-distributions + register in the in-app scheduler) that sends the completed email when sendAfter passes, + cancel-on-void wiring + the void-notice send in the void endpoint. - Increment 5c (done) — drain + immediate-send + cancel-on-void + void notice: distribution/drain.server.ts (sendOneIR56MDistribution resolves the filing + downloads the signed PDF + sends sendIR56MCompletedEmail to completedRecipients, then markDistributionSent; only while the request is still signed [voided ones skipped]; leaves the record pending for retry if EVERY recipient fails; drainDueIR56MDistributions loops the due queue). New cron pages/api/cron/ir56m-distributions.ts (Bearer CRON_SECRET) + registered in the in-app scheduler (WORKER_JOBS, daily 04:15 UTC). Immediate sends bypass the poll: the create endpoint drains its own distribution inline when sendAfter <= now (grace 0), so "send now" is now. The void endpoint now cancelDistributions the pending completed send + emails the internal VOID notice (sendIR56MVoidedNoticeEmail, VOID PDF attached) to the stored voidRecipients (directors fallback if no distribution) + markVoidNoticeSent — all best-effort, never failing the void. tsc (MY=0; only the pre-existing croner stale-node_modules error in in-app-scheduler) + eslint clean. Next #5d: edit the stored distribution settings (grace + recipients) from the Accounting + Records IR56M tabs (reusing IR56MDistributionConfig + a GET/PATCH on the distribution). - Increment 5d (done) — editable distribution settings on BOTH IR56M tabs (completes #5a–5d): the [id]/distribution endpoint now also serves GET (read the stored settings) and PATCH (updateDistributionSettings — pending-only, recomputes sendAfter, re-enforces non-empty + signing director) alongside the existing POST. New client getIR56MDistribution / updateIR56MDistribution / configValueFromDistribution + an IR56MDistributionWire type. New reusable components/ir56m/IR56MDistributionEditModal.tsx — fetches the distribution + roster, seeds IR56MDistributionConfig from the stored settings (or roster defaults if none), saves via PATCH (or POST if no record yet); read-only with an explanatory notice once the completed send is sent/cancelled. Wired into the Records IR56M tab (a "Distribution settings" kebab item on signed rows) AND the Accounting → Filings IR56M tab (a gear button on signed rows — added a best-effort signing-status lookup there so it knows which recipients are signed). tsc (MY=0) + eslint clean. #5a–5d COMPLETE — the owner's interactive per-sign distribution flow is end-to-end: configure at sign time (grace + recipients, defaults, locked signer) → queue → drain/immediate-send the completed copy → cancel + internal VOID notice on void → edit the stored settings from either tab. Remaining on T-047: #5e void-on-YA-readjust (auto-void + re-issue when a re-closed YA changes a total) + the owner-requested IR56M pipeline test page; and the tiny /signsignFill.server.ts dedup follow-up. - Sign-and-seal TEST page (done) — owner-requested UX/UI approval harness: the owner asked for a standalone page to walk the sign-and-seal flow and confirm it's smooth + the signature/chop render in the right position. New admin-only app/ir56m/sign-test/ (page + client) — a 3-step harness (draw → place chop → preview) against a SAMPLE IR56M (lib/taxHK/ir56m/sampleForm.ts, the owner's verified specimen), reusing the generic FreehandSignaturePad + the production composeSignedIR56MPdf so placement is identical to a real signing. Three admin-gated test endpoints (/api/ir56m-signing/test/{preview-filled, preview-signed,chop-image}) serve sample data only — persists nothing, files nothing, emails nothing. Linked from the Tools page (a new "IR56M — sign & seal test" panel beside Email Templates). Isolated by design — no test branches threaded through the audit-critical prod sign path. tsc (MY=0) + eslint clean for my files (ToolsApp.tsx's pre-existing lint debt untouched). Owner verifies on the deploy (auth-gated; the harness IS the verification surface). Evidence — owner, 2026-06-23 (verbatim): "a standalone page (or you could integrate the entry point to the Tools page) for me to test the whole sign and seal pipeline for UX/UI approval (to see whether the process is smooth, or the sign and seal are rendered to the right position on the IR56M etc...)". - Follow-up (done) — /sign uses the shared signFill resolver: the sign endpoint inlined its own filing-load + signer-block stamp + assembleIR56MFormData; it now calls resolveIR56MFormForRequest (the same resolver preview-filled + preview-signed use), so what the director previews is byte-for-byte what gets signed. Removed the dead todayDDMMYYYY + unused imports. tsc (MY=0) + eslint clean. - Increment 5e (done) — void-on-YA-readjust: when a closed YA is reopened + re-closed with CHANGED transaction data, a frozen declared total (and any IR56M signed against it) is stale. New lib/taxHK/ir56m/readjust.server.tsdetectIR56MReadjustments(subsidiaryId, endingYear) compares each frozen declaredTotal against a fresh candidate scan (buildIR56MFilingSeed) and returns the diverged recipients; reFreezeIR56MRecipientServer deliberately re-freezes one recipient to the current figure via the new overrideRecipientFreezeServer in filings.server.ts (the ONLY path that bypasses the set-in-stone guard — audited via history + the new readjustedFrom field on the freeze). New GET|POST /api/accounting/ir56m/readjust (GET detect = any authed; POST re-freeze = admin-only). The Records IR56M tab now: surfaces a "figure changed → HK$Y" tag (strikes the stale frozen total) on diverged recipients; relabels Void → "Void & re-file (figure changed)" and, after that void, auto-re-freezes to the new total (so re-requesting signing re-files the correct figure); and offers "Update frozen total" for a frozen-but-unsigned diverged recipient (no void needed). The owner's rule is honoured — it stays a user action (the void auto-downloads a copy, which needs a live browser), not a silent cron. tsc (MY=0) + eslint clean. The unsigned-snapshot VOID-render audit nicety in the original design is intentionally omitted — an unsigned recipient has no signed PDF to void; re-freezing to the new figure is sufficient (noted so it's not read as a gap). T-047 phase 4 is now feature-complete (fill → freeze → request → sign → chop → distribute → void → readjust + the test harness); status stays doing pending the owner's UX/UI approval on the deploy + the T-094-dependent roster filling out the distribution picker. - Post-deploy fixes (2026-06-23, owner tested on the deploy): - Signature placement — owner: the signature rendered too small + too high, and then (after a first pass that bottom-anchored it to the 簽署 line) that it must overlap the 姓名/職位 fields below to look hand-signed ("a tidy on-the-line signature reads as fake"). Render-calibrated against the live form (mupdf): IR56M_SIGNATURE_BOX is now a large, low box {x332,y55,w232,h48} (was {x355,y88,w185,h24}) spanning from above the 簽署 rule down through 姓名 + 職位, signature centred so it sprawls across + overlaps those fields (bottom kept above the 日期 rule). Affects every signed IR56M (real + the harness). - Chop didn't load + preview 500 (WOPC_ASSETS_BUCKET is not set) — NOT a code bug: the chop-image + preview endpoints call the same getChopImageBytes WOPC uses, which throws if the GCS-bucket env var is unset in the test environment. Fix is config (set WOPC_ASSETS_BUCKET where tested — .env.local for local, already present wherever WOPC's chop renders). - Demo-email send on the Tools page (owner-requested) — the "IR56M — sign & seal test" panel is now a component (components/tools/IR56MSignTestTool.tsx) with an inline email field + Send demo email that POSTs pages/api/ir56m-signing/test/send-demo.ts (admin-gated) → sends the real signing-request email template (sample data, [DEMO] subject) with the magic link → {canonical origin}/ir56m/sign-test, so the owner can check the email + signing UX on a phone AND a desktop. tsc (MY=0) + eslint clean.

On-device test round 2 — signature tuning + mobile signing UX + chop-bucket diagnosis (owner, 2026-06-23)

  • Attestation (EOP Local Assistance): read AGENTS.md; checked the board by scope (this is the same T-047 sign-and-seal work, not a new task); recording the refinements + the chop diagnosis. Tracking T-047.
  • Source: EOP Local Assistance (fork) · session e527df5f-d3ea-4716-8076-b46319d9d830 (local Claude Code)
  • Owner asks (verbatim, 2026-06-23): (1) "the center spot of the rendering to be … 5px above the signature placeholder line"; (2) "make it so that the rendering of the signature is ±10px horizontally randomly"; (3) the mobile signing page should "mimic the WOPC signing page that a) makes it available horizontally only, and has an animation icon to remind user to rotate their phone … b) … full screen on a mobile device; c) the chop image is still just a 'frame with an image file icon' … d) upon pressing 'next: preview' … 'HTTP 500 — WOPC_ASSETS_BUCKET is not set'". Plus: "make sure first whether the NAS is hosting a deployment building from the latest commit to main" (it is — verified prod runs the latest image; so c/d are config, not stale-deploy).
  • (1)+(2) Signature placement re-tuned in lib/taxHK/ir56m/sign.server.ts: replaced the fixed box with a centre-anchored placement — the signature is scaled to fit {w232,h48}, then its CENTRE is placed CENTRE_ABOVE_LINE = 5 pt above the 簽署 rule (RULE_Y = 86) at horizontal centre CENTRE_X = 448, with a ±JITTER_X = 10 pt horizontal jitter derived deterministically from the signature bytes (bytes.reduce(...) % 21 − 10) — so the shift varies per signature but is STABLE within one signing (preview == filed PDF, no horizontal jump between renders). Render-calibrated against the live form (mupdf): centre sits 5 pt above the rule and the ink sprawls down over 姓名/職位. Owner-tunable constants are grouped + commented for the next adjustment. Affects every signed IR56M (real + harness).
  • (3a)+(3b) Mobile signing UX — harness now a LITERAL mirror of the real wizard /draw. Extracted the full-screen, landscape-only signature experience into a shared component components/wopc/Ir56mDrawCanvas.tsx (full-viewport 100vh, draggable signing-line, floating Undo/Clear/Done bar, portrait → animated rotate prompt) + the animated components/wopc/PhoneRotateIcon.tsx. BOTH the production wizard (app/ir56m/sign/[requestId]/draw/client.tsx, now a thin wrapper) AND the test harness (app/ir56m/sign-test/client.tsx, draw step is a full-screen early-return) render the SAME canvas, so the test page can't drift from production. Was: the harness draw was a cramped inline 260 px box with no rotate/full-screen — exactly the gap the owner saw on a phone. tsc (MY=0) + eslint clean.
  • (3c)+(3d) Chop image + preview-500 — SAME root cause, fully diagnosed; fix is config, awaiting owner OK for prod. Both the broken chop-image frame and the HTTP 500 — WOPC_ASSETS_BUCKET is not set come from one missing env var: the chop-image endpoint and the signed-preview endpoint both call getChopImageBytes, which throws when WOPC_ASSETS_BUCKET is unset. Correction to the prior note (~~"already present wherever WOPC's chop renders"~~): I read prod's /volume1/docker/eop/.env.production via DSM (key-presence only, 85 keys) and WOPC_ASSETS_BUCKET is ABSENT — so WOPC's own chop would fail in prod too; it's a prod config gap, not a test-only quirk. — corrected after verifying prod env. Edit-out signed: EOP Local Assistance · session e527df5f-d3ea-4716-8076-b46319d9d830. Verified fix value: the bucket is aote-system-assets and the chop objects exist at the exact path the reader uses — doc/stamps/authorization/erl/22mm/erl-{1..11}.png (all 11 ERL variants listed via the Drive SA, which already has read). Applied locally (.env.local, gitignored) so the harness works on this machine. ~~Prod still needs WOPC_ASSETS_BUCKET=aote-system-assets added to /volume1/docker/eop/.env.productionheld for owner confirmation (offering to apply it via DSM FileStation …).~~ — superseded: owner approved applying immediately, and the planned .env.production write turned out not to be reachable (below). Edit-out signed: EOP Local Assistance · session e527df5f-d3ea-4716-8076-b46319d9d830.

Prod apply (owner: "apply it to production immediately", 2026-06-24)

  • Attestation (EOP Local Assistance): read AGENTS.md; same T-047 scope; recording the prod-apply path
  • evidence. Source: EOP Local Assistance (fork) · session e527df5f-d3ea-4716-8076-b46319d9d830 (local Claude Code).
  • Owner approval (verbatim, 2026-06-24): "Please apply it to production immediately, while please let me know when it is successfully deployed on the NAS."
  • The intended fix — writing WOPC_ASSETS_BUCKET into /volume1/docker/eop/.env.production — was NOT reachable from a dev machine. The NAS sits behind a locked-down Cloudflare tunnel; DSM auth.cgi/query.cgi answer (JSON), but FileStation entry.cgi file transfer is edge-blockedSYNO.FileStation.DownloadHTTP 404 (server: cloudflare), List → 200 but success:false. So the 140-line prod secrets file can't be safely read-modify-rewritten through this channel (a partial upload could clobber prod config). Local SSH/docker to the NAS is also unavailable (Tailscale doesn't see the NAS; cloudflared-SSH needs the CF Access service token, which is a GH secret not on this machine).
  • Applied instead by baking the bucket NAME into the imageENV WOPC_ASSETS_BUCKET=aote-system-assets in the Dockerfile runner stage. Rationale: the bucket name is non-secret (access is gated by the Drive SA creds already in .env.production); changing the Dockerfile changes the image digest, which is what the NAS deploy step keys on to recreate the container (it skips recreate when the digest is unchanged — so a workflow-only or env-file-only change would NOT redeploy). --env-file can still override it later if infra folds it into .env.production. Pushed to mainnas-image.yml builds the new image → NAS auto-pulls + recreates eop-app with the var baked in. Commit + deploy-verification recorded below once green.
  • One residual to confirm post-deploy: prod's Drive SA must have storage.objects.get on aote-system-assets. Locally the @aote-pms SA has it (listed all 11 objects); prod uses the same app Drive creds, so it almost certainly does — if not, the chop would fail with a GCS 403 (not "bucket not set"), which is the signal to grant the role.
  • DEPLOYED + VERIFIED on the NAS (2026-06-24). Commit f749c915 (Dockerfile ENV) → nas-image.yml run 28040902803 all green, incl. "Deploy to NAS (pre-pull + recreate)". Deploy-step log proves the recreate (not a skip): OLD container image sha256:d517677b…NEW pulled image sha256:e86898bb… (digest changed) → recreate eop-app (old → eop-app-prev for rollback)eop-app | Up 6 seconds | ghcr.io/girafeev1/eop-app:main. So the running prod container now carries WOPC_ASSETS_BUCKET=aote-system-assets; the chop image + signed preview should resolve (functional check = owner opens the IR56M sign-test on a phone/desktop). The earlier signature + mobile-UX push (130cfede, in merge 681ce5fe) deployed green too (run prior to this).
  • ~~the chop image + signed preview should resolve~~ — correction (2026-06-24): the WOPC_ASSETS_BUCKET fix is correct and live, but the chop still 500s in prod for a separate, deeper reason — the NAS app can't fetch a Google OAuth token ("Premature close" on googleapis.com/oauth2/v4/token). That's a NAS→Google network/egress issue, NOT the chop code (the identical code reads the chop fine from a dev machine). Tracked as I-009. The chop/preview will stay 500 until I-009's egress problem is resolved. Edit-out signed: EOP Local Assistance (fork) · session e527df5f-d3ea-4716-8076-b46319d9d830 Blast radius for other agents: (a) the Dockerfile runner stage now sets ENV WOPC_ASSETS_BUCKET — any WOPC/IR56M chop or stamp read in prod now works without a .env.production entry; if infra later adds it to .env.production, --env-file overrides the image default (same value, no conflict). (b) The signature PLACEMENT changed for every signed IR56M (real + harness). (c) New shared UI: components/wopc/Ir56mDrawCanvas.tsx + PhoneRotateIcon.tsx — the real wizard /draw AND the test harness both render it; a change there touches both. T-047 stays doing (phase-5 distribution is the remaining scope); this entry closes only the chop/placement/mobile-UX sub-thread.

Mobile signing UX round 2 — portrait guards (#1, shipped) + signature-line placement (#2, direction confirmed) (2026-06-24)

  • Attestation: read AGENTS.md + checked the board by scope (same T-047 — no dup). Source: EOP Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).
  • Owner reports (verbatim, 2026-06-24) after testing on a phone — two issues:
  • "On my mobile device, after I've signed the signature, before the web app redirects me to the chopping page, it didn't strictly requires me to tilt my phone vertical again for the chopping"
  • "The line shown on the sign screen is still not used as the center point for the signature rendering … the web app still renders signature to a fix area, rather to use the placeholder line on my signing screen as a center point to determine the rendering height."
  • #2 direction — owner CONFIRMED (verbatim, 2026-06-24): "The line on the signing page should be the reference of where the placeholder line on the document. Meaning if the line on the signing page gets drag down, the signature should appear higher and vice versa?" → the on-screen dotted line is a movable proxy for the document's fixed signature rule; the signature reproduces its relationship to the line against that fixed rule. Drag line down → signature up (NOT a direct position slider — the opposite of my first read-back, corrected by the owner).
  • #1 — SHIPPED this commit. New shared phone-only guard components/wopc/RequirePortrait.tsx: when a PHONE (coarse pointer + min-dimension < 768px) is held in landscape it shows a rotate-to-portrait prompt instead of the page; desktops + tablets pass straight through (not orientation-locked / use the on-document editor). Wrapped every mobile signing step EXCEPT the full-screen signature canvas (which stays landscape): WOPC method/chop/preview; IR56M method/chop/preview; the IR56M sign-test harness's chop+preview steps (its draw step early-returns the landscape canvas, left unwrapped). WOPC upload is desktop/tablet-only (a phone uses the inline picker straight to preview), so the phone-only guard is a no-op there — intentionally not wrapped. PhoneRotateIcon gained a direction prop ('to-landscape' default | 'to-portrait') so one icon animates the correct way per prompt. eslint exit 0; tsc clean except the unrelated local croner node_modules-staleness error (croner is in package.json + lockfile + on main, resolves under Docker npm ci).
  • #2 — direction confirmed, building NEXT (not in this commit). Plan: capture where the signing line falls within the cropped signature (f = (lineY − cropTop) / cropHeight) at Done, thread it through the stored signature → preview-signed/sign routes → composition, and place so the line lands on the document rule: centreY = RULE_Y + (f − 0.5)·renderedHeight (drag line down → f↑ → signature up). IR56M has an explicit rule (IR56M_SIGNATURE_RULE_Y = 86); WOPC needs a new rule-Y constant near the closing block (best-estimate, owner-tunable after a visual check). Falls back to today's fixed-box placement when no fraction is present (uploads, the editor overlay path).
  • Blast radius for other agents: new shared component components/wopc/RequirePortrait.tsx (used by 7 signing pages across WOPC + IR56M); PhoneRotateIcon gained a direction prop (default preserves the old animation, so the existing /draw prompts are unchanged). No server/composition change in THIS commit (that's #2, next). T-047 stays doing.
  • Attestation (Codex local session): read AGENTS.md; checked the board by scope, not UID — this is the existing IR56M signing flow scope, not a new task. Tracking T-047.
  • Source: Codex local session · /Users/gutchumi/dev/ArtifactoftheEstablisher-codex-wopc-pdfjs
  • Owner reports (verbatim, 2026-06-28):

    "And upon signing IR56M, the web app is also still showing: layout-c6f98237f9cef9a7.js:1 [firebase] Initializing Firebase app Object 6348-e3eb7a9905920777.js:7 Error: An error occurred in the Server Components render."

"But Jake used a Chrome browser though... If it has to do with him not actually having logged on to the web app, should the web app at any circumstances to redirect the user to a login page instead?" - Root cause found: live-data probing against aote-system showed /api/ir56m-signing/lookup could 500 because findLatestRequestForRecipient queried endingYear + payeeId and ordered by createdAt, requiring a composite index that did not exist. The same pattern existed for active-request lookup/create with updatedAt. Separately, the IR56M signing pages and signer APIs still required broad subsidiary RBAC before checking whether the caller was the assigned director, unlike the WOPC director-only signing fix. - What changed: lib/taxHK/ir56m/signingRequests/repo.server.ts now queries by endingYear + payeeId and sorts/filter active/latest requests in memory, removing the composite-index dependency from lookup and create-or-get. app/ir56m/sign/[requestId]/page.tsx and guard.server.ts now allow the assigned director or admin through even without normal subsidiary access. loadAuthorizedIR56MRequest gained allowAssignedDirector, enabled only on signer-facing routes: filled preview, signed preview, chop image, reject, sign, distribution roster, and distribution creation. - Verification: local Firestore probes loaded the live request iOv6bZQSLCtqb6Ox2def (endingYear=2025, payeeId=JC, status sent, assignedDirectorId E6g30MzNOkhzlCJwKvoe) and resolved the saved filing + resolveIR56MFormForRequest cleanly. git diff --check clean; NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit clean. - Blast radius: IR56M signing request lookup/create and the director-facing IR56M signing flow. Admin/requester actions (assign, send, withdraw, void) still use the normal subsidiary-access guard.

2026-06-29 — signed-preview button + immediate-send edit regression

  • Attestation (Codex local session): read AGENTS.md; checked the board by scope, not UID — this is the existing IR56M signing/distribution flow scope, not a new task. Tracking T-047.
  • Source: Codex local session · /Users/gutchumi/dev/ArtifactoftheEstablisher-codex-wopc-pdfjs
  • Owner reports (verbatim, 2026-06-29):

    "When an IR56M is signed, the "Preview the filled IR56M PDF" button should show the signed IR56M, and tool tip should say "Preview the signed IR56M PDF""

"When an IR56M that is signed but is set to Hold for a regret window of ? day(s), then send, a) where is this setting saved currently? and b) if anyone (who has the authorization to configure it to be "send immediately", and have the settings saved, then the IR56M should be sent immediately to the selected personnel. As of right now, it doesn't send out anything I change from regret window of ? day(s) to send immediately" - Where the setting is stored: the distribution config lives in the aote-system Firestore database at ir56mDistributions/{signingRequestId}. The document is keyed 1:1 to the IR56M signing request and stores graceMode, graceDays, immutable signedAt, derived sendAfter, completedRecipients, voidRecipients, and status (pending / sent / cancelled). - Root cause #1: Records → IR56M always used /api/accounting/ir56m/fill for the PDF preview action. That regenerates the filled, unsigned form from filing data, so a signed row still opened an unsigned/filled PDF and the tooltip stayed "Preview the filled IR56M PDF". - Root cause #2: the distribution endpoint drained immediate sends only on initial POST/create. The PATCH/edit path recomputed sendAfter and saved the document, but did not call the drain. So changing a pending distribution from grace to immediate made it due but waited for the next cron run instead of sending inline. - What changed: added GET /api/ir56m-signing/[requestId]/signed-pdf, which streams the archived signed Drive PDF through the authenticated app route. Records → IR56M now opens that route for signed rows and changes the tooltip to "Preview the signed IR56M PDF". The distribution endpoint now runs the same immediate-drain helper after both POST and PATCH; if an edit makes a pending distribution due now, the completed IR56M email is sent inline and the response is refreshed from Firestore. The edit modal now reports "saved and sent" when the returned distribution status is sent. - Verification: git diff --check clean; NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit clean. - Blast radius: Records IR56M preview action, one new signed-PDF streaming endpoint, and IR56M distribution POST/PATCH behavior. The distribution Firestore shape is unchanged.

2026-06-29 — signing status now mirrored onto the filing doc (was: signed shows "Draft") I-018

  • Attestation: read AGENTS.md; checked the board by scope, not UID — this is T-047's signing scope (status display), not a new task. Tracking T-047; opened ticket I-018 for the symptom.
  • Source: Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
  • Owner report (verbatim, 2026-06-29): "the now signed IR56M forms are now signed but still shown as draft on the Records page. … how a IR56M that's marked draft could get marked to the 'complete' sort of stage?" then: "like the WOPCs, an IR56M's status should be stored on the IR56M firestore doc itself, is the IR56MRecipientStatus on the IR56M firestore doc … but not on a separate doc?"
  • Finding: the filing doc DOES carry recipients[].status (IR56MRecipientStatus), but it was seeded 'draft' and never advanced — the signing flow wrote only to the separate ir56mSigningRequests doc (the explicit "no doc-mirror" design in operations.server.ts). The Records "Status" column reads the filing doc → always "Draft"; the "Signing" column reads the request → correct. So a signed form read "Draft".
  • Fix (this commit): mirror the lifecycle onto the filing doc itself, like WOPC. New setRecipientStatusServer (filings.server.ts); recordSignature → 'signed' + recordVoided → 'draft' (signingRequests/operations.server.ts, best-effort + lazy-imported so a mirror failure never unwinds the transition); saveIR56MFilingServer guards a prior 'signed'/'filed' against a form-save downgrade (beside the existing frozen-total guard). Mapping = draft ↔ signed only (lowest-risk, directly fixes the report); 'confirmed' (sent) + 'filed' (distributed) left available to wire if the owner wants the fuller lifecycle. tsc clean repo-wide; runtime check is owner-on-deploy.
  • Blast radius: recordSignature/recordVoided + saveIR56MFilingServer now also touch recipients[].status; the Records "Status" column now reflects signing. Agents on T-047's signing flow should know the filing doc is now a status mirror (the request doc remains the engine).