Invoice doc shape migration — consolidate to top-level `detail` + `email` maps
Why (owner, 2026-06-20)¶
Invoice docs are mid-migration from flat keys to nested maps (the partial work in
lib/invoiceDocShape.ts: lineItems, invoiceDetail, paymentDetail). Owner asked to
finish the consolidation by:
- collapsing invoiceDetail + paymentDetail + lineItems + invoiceTotal under a
single top-level detail map;
- grouping all email* fields under a top-level email map (separate from detail
— communications/audit, not substantive invoice fields);
- dropping field-name prefixes inside the maps (detail.invoice.drafted, not
detail.invoice.invoiceDrafted);
- adding detail.payment.tx: string[] as a bidirectional audit trail of matched bank
txs (so the invoice still points back even if the app vanishes — companion to the
canonical gl['4000'] allocation on the tx side).
Applies to all three invoice surfaces that share lib/invoiceDocShape.ts: project
invoices (tebs-erl), coaching invoices (tebs-mel), and quotations.
Target shape¶
top-level:
detail: {
invoice: { drafted, created, issued, cleared }
payment: { tx: [<txId>, …], payTo }
lineItems: { item1, item2, … itemN }
invoiceTotal:{ amount, subtotal, total, taxOrDiscount, discountSpecification }
}
email: { sentAt, recipients, cc, bcc, messageId, deliveryStatus, history }
// subsidiaryId, clientCompany, recordStatus, createdAt, updatedAt — top-level
detail.payment.status) — handled by T-075
(evidence-based status), not this PR.
Plan¶
- Extend
lib/invoiceDocShape.ts: - Update accessors (
getInvoiceDrafted,getInvoiceCreated,getInvoiceIssued,getInvoiceCleared,getPayTo,getInvoiceTotal,enumerateLineItems) to readdetail.*first, falling back to existinginvoiceDetail/paymentDetail/flat. - New accessors:
getEmail*,getPaymentTxIds. - Extend
transformPayloadToNestedShapeto emitdetail.*+email.*, with UPDATE-vs-CREATE switch + atomic legacy-key deletes (same pattern as today). - Match/unmatch paths in
lib/accounting/transactions.tsmaintaindetail.payment.txviaFieldValue.arrayUnion/arrayRemove. Project invoices (same DB) batched; coaching invoices (cross-DB) accept the same not-atomic risk we already live with forSessions.paidetc. - Update writers in
lib/projectInvoices.server.ts+pages/api/invoices/send.tsto write the new shape (transform does the heavy lifting). - Migration script
scripts/migrate-invoice-doc-shape.tswalks every invoice + quotation + coaching invoice; reads from any of the three legacy shapes, writes the new canonicaldetail.*+email.*, deletes the legacy keys. Backup-first, dry-run, idempotent, halt-on-error (mirrorsmigrate-gl-5020-to-5050.ts). - Backfill
detail.invoice.drafted+detail.invoice.createdon docs that never had them, using the existing fallback chain (updatedAt→invoiceIssued→ invoice-number-suffix decode).
Out-of-scope (separate tasks)¶
- Dropping the stored payment status label → T-075.
- Coaching schema cleanup (sessions under students, invoice as map field on session) → T-078.
Related¶
T-075 (sibling — same architectural cleanup, completes T-075's gl[4001] work) · T-076 (depends on this PR shipping first) · T-079 (next in the chain).
Log¶
- 2026-06-20 created. Plan agreed with owner after multi-turn architecture discussion
(final shape:
emailtop-level for audit/comms;detail.invoiceTotalfor uniformity; prefix-drop inside maps;detail.payment.tx[]as bidirectional audit trail). - 2026-06-20 SHIPPED in PR #763 (claude/invoice-detail-email-shape-yxMLM, base nightly). Three phases as separate commits: · Phase 1 (f6002a2): lib/invoiceDocShape.ts extended with new accessors (getEmail, getPaymentTxIds, getInvoiceTotal) and 3-tier fallback (detail. → invoiceDetail/ paymentDetail/lineItems/flat email → flat top-level). transformPayloadToNestedShape accepts prior-tier inputs via a hoist pre-pass. Server-side buildInvoiceWritePayload routed through the transform; send.ts likewise. · Phase 2 (db24a4b): detail.payment.tx[] write trail wired into all 4 match/unmatch paths via syncProjectInvoiceTxTrail + syncCoachingInvoiceTxTrail helpers. · Phase 3 (23ddbf0): scripts/migrate-invoice-doc-shape.ts walks tebs-erl + tebs-mel invoice docs, normalises to detail. + email.*, deletes legacy keys, backfills detail.invoice.drafted / created via the existing fallback chain. tsc + vitest accounting (114) clean. PR marked ready for review. Owner to run --dry-run then --apply once the chain merges.
- 2026-06-20 status flipped to done — Accounting [Infrastructure Development] (housekeeping; the work itself shipped via PR #767 and the T-080 cleanup chain). Source: Accounting [Infrastructure Development] · https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM (Source line retrofitted 2026-06-21 — the convention postdates this entry.)
2026-07-03 — hotfix: invoice preview accepted split-shape survivors + prefixed invoice ids¶
- ✅ Attestation (Codex local session): read
AGENTS.md; checked the board by scope, not UID — this is the existing invoice doc-shape / preview-read scope, not a new task. Source: Codex local session ·/Users/gutchumi/dev/ArtifactoftheEstablisher-codex-invoice-hotfix - Owner report (verbatim, 2026-07-03):
"Upon having an invoice created and with the web app's guidance proceeding into the invoice preview page, the web app shows \"Invoice not found\". (link here) Can you investigate why? And upon viewing an invoice in the preview page for an existed invoice, the page showed this \"Bank information incomplete No bank account selected (payTo field not set)\""
- Live Firestore findings:
tebs-erl/projects/2026/projects/2026-005/invoice/ERL-2026-005-0601exists, but the owner-facing link carried unprefixed2026-005-0601; the preview API was exact-matching and returned 404.tebs-erl/projects/2026/projects/2026-004/invoice/ERL-2026-004-0508carriedpaymentDetail.payTo = "ERL-AWX-HKD"whiledetail.payment.payTowas absent, so the canonical-only accessor returned empty bank instructions. - What changed:
/api/invoices/[year]/[projectId]/[invoiceNumber]now fetches invoices using the project document id resolved byfindProjectAcrossDatabases()and matches both prefixed and unprefixed invoice numbers.lib/invoiceDocShape.tsnow readsdetail.*first but tolerates partial-nested survivors (invoiceDetail,paymentDetail, and older payTo aliases).transformPayloadToNestedShape()also foldsinvoiceDetail/paymentDetailinto canonicaldetail.invoice/detail.paymenton writes so new invoices stop preserving the split shape. - Verification:
__tests__/lib/invoiceDocShape.test.tsadded; targeted Vitest passed;tsc --noEmitpassed after linking the existing localnode_modulesinto the clean hotfix worktree; direct patched server-helper verification against Firestore matched2026-005-0601 → ERL-2026-005-0601and returnedpayTo: ERL-AWX-HKDfor both owner examples. - Blast radius: project invoice preview API, invoice doc-shape accessors, and invoice write-shape transform. No Firestore data was mutated by this hotfix; it is read-tolerant and write-forward-correcting.
2026-07-03 — follow-up: line items were also split-shape survivors¶
- ✅ Attestation (Codex local session): read
AGENTS.md; checked the board by scope, not UID — same invoice doc-shape / preview-read scope as the hotfix above. Source: Codex local session ·/Users/gutchumi/dev/ArtifactoftheEstablisher-codex-invoice-hotfix - Owner follow-up: invoice previews loaded after the first hotfix but showed no invoice detail. Live API responses confirmed the records were found and
payTowas restored, butitems: []andtotal: 0were returned. - Live Firestore findings: the two owner examples still carry item maps outside the canonical path.
ERL-2026-005-0601has top-levellineItems.item1/item2.ERL-2026-004-0508has an emptydetail.lineItemsmap plus top-levellineItems.item1/item2. The previous accessor treated the empty canonical map as authoritative, so it never fell back. - What changed:
enumerateLineItems()andgetLineItem()now fall back to top-levellineItemswhen canonicaldetail.lineItemsis absent or contains noitemNentries.transformPayloadToNestedShape()also folds top-levellineItemsforward intodetail.lineItemsso future writes stop preserving the split shape. - Verification: targeted Vitest passed;
tsc --noEmitpassed; direct patched server-helper verification against Firestore returned 2 items / total 2500 for2026-005-0601and 2 items / total 3500 forERL-2026-004-0508.