Skip to content

Invoice edit path DELETES canonical detail.payment.payTo and stashes it in legacy paymentDetail.payTo — every edit re-drifts the bank account

Symptom

The invoice preview showed "Bank information incomplete — No bank account selected (payTo field not set)" for invoices that DO have a bank account assigned. On the live site this was compounded by a deploy lag (the reader fix below wasn't yet in production); after redeploy the message is gone, but the underlying data drift is real and ongoing — this ticket records the write-side cause the reader fix only masks.

Root cause (proven — real transform run against the real payload)

Two interacting defects in the invoice write path make every edit move the bank account OUT of the canonical field and into a dead legacy field:

  1. Wrong target slot. buildInvoiceWritePayload (lib/projectInvoices.server.ts:676-689, UPDATE branch) writes the real value to the legacy key payload['paymentDetail.payTo']. The canonical location is detail.payment.payTo (T-078 shape). The transform (transformPayloadToNestedShape, lib/invoiceDocShape.ts) only promotes paymentDetaildetail.payment when paymentDetail is a whole object (if (isObjectMap(next.paymentDetail)), line ~271). On UPDATE the value arrives as a dotted-path string key ('paymentDetail.payTo'), so that check is false and the value passes straight through to the doc's legacy slot.

  2. The cleanup line actively deletes the canonical field. The same branch does payload.payTo = FieldValue.delete() (line 683) to clean the top-level legacy payTo. But the transform's FLAT_TO_PAYMENT_KEY maps top-level payTodetail.payment.payTo, so that delete sentinel becomes detail.payment.payTo = DELETE — it wipes the canonical bank account on every edit.

Demonstrated (real transformPayloadToNestedShape, update payload)

Input (what the edit path emits): { 'paymentDetail.payTo': 'ERL-AWX-HKD', paymentStatus: 'Draft', payTo: <delete> } Output:

detail.payment.status  = "Draft"
detail.payment.payTo   = <DELETE>          ← canonical field DELETED  ✗✗
paymentDetail.payTo    = "ERL-AWX-HKD"      ← value lands in legacy slot  ✗

Corroborated by the live updateLog on ERL-2026-004-0508 (2026-07-03 06:32): a field=detail write whose OLD detail.payment had payTo:"ERL-AWX-HKD" and NEW did not, immediately followed by a field=paymentDetail write of {payTo:"ERL-AWX-HKD"}. The create path is fine — it emits paymentDetail as an object, which the transform does promote to detail.payment.payTo.

Why it looked "fine" then broke

  • Masked by the reader: commit dcd1b631 (2026-07-03, "fix(invoices): resolve preview doc shape drift") made getPayTo fall back to legacyPaymentDetail(data)?.payTo, so the UI resolves the bank account from the legacy slot. That commit is reader/resolver-only — it did not touch the write path, so the drift continues.
  • Deploy lag: production ran a pre-dcd1b631 build, so before the redeploy the fallback wasn't live and the preview genuinely couldn't find payTo → the owner's report.

Blast radius (live scan, tebs-erl, 2026-07-10)

31 project invoices: 29 still have canonical detail.payment.payTo; 2 are legacy-only — the two recently-edited ones (ERL-2026-004-0508, ERL-2026-005-0601), canonical field deleted, value in paymentDetail.payTo. The count grows by one for every invoice edited until the write path is fixed. 0 invoices have no payTo at all. (Coaching invoices in tebs-mel use a different doc layout — not assessed here.)

In buildInvoiceWritePayload (UPDATE branch, and the payTo-preservation branch at line ~662), emit payTo to the canonical location and stop deleting it:

// instead of: payload['paymentDetail.payTo'] = input.payTo; payload.payTo = FieldValue.delete()
payload.payTo = input.payTo                          // transform maps top-level payTo → detail.payment.payTo
payload['paymentDetail.payTo'] = FieldValue.delete() // clean the LEGACY slot (dotted delete passes through)

(Top-level payTo: <value> is what the transform's FLAT_TO_PAYMENT_KEY already routes to detail.payment.payTo, verified in the repro above.) Then a one-time data normalization for the 2 drifted docs: move paymentDetail.payTodetail.payment.payTo, delete the legacy key. Add a unit test that feeds the update payload through transformPayloadToNestedShape and asserts detail.payment.payTo === value (not a delete sentinel) — the exact gap dcd1b631's reader-only tests didn't cover.

The reader fallback can stay as defense-in-depth, but it should not be load-bearing.

Origin trace — which task created this bug, and its original intention

Traced through the full (un-shallowed) git history. The defect is a half-completed two-step schema migration: the writer was left emitting the shape of step 1 after step 2 moved the goalposts.

Step 0 — payTo under clientCompany. 842c9cfa (2026-04-14, "move payTo under clientCompany map").

Step 1 — the nested-map migration (PRs #538 + #541, 2026-05-31). 7d0267c0 (Phase 1, dual-read accessors) + 3d5e2ada (Phase 2, "write nested shape + forward-migrate") authored the exact lines in buildInvoiceWritePayload this ticket is about. Phase-2's declared write shape was: paymentDetail: { paymentStatus, invoiceIssued, invoiceCleared, payTo } + clientCompany without payTo, and a forward-migration that emits FieldValue.delete() for legacy top-level keys (incl. top-level payTo) on every UPDATE. At Phase-2 this code was CORRECT: paymentDetail.payTo was the canonical home, and payload.payTo = FieldValue.delete() cleaned a genuinely-legacy top-level field.

Step 2 — the consolidation, T-078 (originally T-077), 2026-06-20. Commit f6002a28 ("T-077 phase 1 — detail. + email. canonical shape") collapsed invoiceDetail/paymentDetail/lineItems/ invoiceTotal under one top-level detail map, so payTo's canonical home moved paymentDetail.payTodetail.payment.payTo. T-078's original intention (verbatim, its plan step 3): "Update writers in lib/projectInvoices.server.ts + pages/api/invoices/send.ts to write the new shape (transform does the heavy lifting)" — and the commit message says the writer "can keep writing prior-tier." So T-078 deliberately did not rewrite buildInvoiceWritePayload (verified: no commit after 3d5e2ada touched those payTo lines); it relied on transformPayloadToNestedShape to convert the writer's prior-tier paymentDetail.* output into canonical detail.payment.*.

How that intention became the bug. The "transform does the heavy lifting" contract was not fully honoured for payTo on the UPDATE path: - The transform only promotes paymentDetail when it arrives as a whole object (create path). On UPDATE the writer emits the dotted key 'paymentDetail.payTo', which the promotion misses → value leaks to the now-legacy slot. (That object-promotion, isObjectMap(next.paymentDetail), wasn't even added until dcd1b631 on 2026-07-03 — so between T-078 and July 3 the transform promoted paymentDetail not at all.) - T-078 also made the transform map top-level payTodetail.payment.payTo. That silently repurposed Phase-2's still-present legacy-cleanup payload.payTo = FieldValue.delete() into detail.payment.payTo = DELETE — actively deleting the new canonical field. The paymentDetail promotion's !('payTo' in createPayment) guard then lets the delete win over the real value.

So T-078's one-time migration script wrote every doc's canonical detail.payment.payTo correctly (why 29/31 are still fine), but every subsequent edit re-runs the un-migrated writer, deleting the canonical field and stashing payTo in the legacy slot. T-080 later removed the legacy read-fallbacks (would have unmasked it); dcd1b631 re-added the read fallback + fixed the create path, masking the symptom without fixing the UPDATE writer.

One-line answer: the bug was created by T-078 choosing to leave the invoice writer emitting the Phase-2 (paymentDetail.payTo) shape and delegate conversion to transformPayloadToNestedShape, whose payTo promotion was incomplete on the UPDATE path and whose top-level-payTo mapping turned Phase-2's legacy-delete into a canonical-delete. The proper fix is the one already in this ticket (point the writer at the canonical field + stop deleting it); the origin explains why a "reader-only" patch (dcd1b631) can't close it.

Decision log

  • Attestation (Projects (Infrastructure)): read AGENTS.md; scope-scanned the board — the recent invoice-shape work (dcd1b631 reader fix, 14bd0401 split-shape line items) is adjacent, but no existing ticket/task records this write-side payTo deletion. Not a dup.
  • Source: Projects (Infrastructure) · https://claude.ai/code/session_01Q2xAAEtBSMfLUGjP1HAA5D
  • Proposed + approved: the owner ("Please file a quick ticket so it's on records, and can you please proceed to investigate?", 2026-07-10) — asked for the ticket + investigation; the write-path fix is not yet authorized, so this stays diagnosis-only.
  • Status: open. The symptom is masked in production by the deployed reader fallback (dcd1b631), so it is not user-visible right now, but the canonical field keeps getting deleted on each edit. Escalate to a small T-NNN if the owner wants the write-path fix + data normalization + regression test.
  • Blast-radius note for other agents: touches lib/projectInvoices.server.ts:buildInvoiceWritePayload and its interaction with lib/invoiceDocShape.ts:transformPayloadToNestedShape — anyone working the invoice doc-shape (the dcd1b631 / 14bd0401 line of work) should coordinate here before changing payTo handling.