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:
-
Wrong target slot.
buildInvoiceWritePayload(lib/projectInvoices.server.ts:676-689, UPDATE branch) writes the real value to the legacy keypayload['paymentDetail.payTo']. The canonical location isdetail.payment.payTo(T-078 shape). The transform (transformPayloadToNestedShape,lib/invoiceDocShape.ts) only promotespaymentDetail→detail.paymentwhenpaymentDetailis 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. -
The cleanup line actively deletes the canonical field. The same branch does
payload.payTo = FieldValue.delete()(line 683) to clean the top-level legacypayTo. But the transform'sFLAT_TO_PAYMENT_KEYmaps top-levelpayTo→detail.payment.payTo, so that delete sentinel becomesdetail.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") madegetPayTofall back tolegacyPaymentDetail(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-
dcd1b631build, 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.)
Recommended fix (write path — not yet applied)¶
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.payTo → detail.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.payTo → detail.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 payTo → detail.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¶
2026-07-10 — opened; root cause proven, write-fix recommended (not applied)¶
- ✅ Attestation (Projects (Infrastructure)): read
AGENTS.md; scope-scanned the board — the recent invoice-shape work (dcd1b631reader fix,14bd0401split-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 smallT-NNNif the owner wants the write-path fix + data normalization + regression test. - Blast-radius note for other agents: touches
lib/projectInvoices.server.ts:buildInvoiceWritePayloadand its interaction withlib/invoiceDocShape.ts:transformPayloadToNestedShape— anyone working the invoice doc-shape (the dcd1b631 / 14bd0401 line of work) should coordinate here before changing payTo handling.