Skip to content

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
Also drop the status string (detail.payment.status) — handled by T-075 (evidence-based status), not this PR.

Plan

  1. Extend lib/invoiceDocShape.ts:
  2. Update accessors (getInvoiceDrafted, getInvoiceCreated, getInvoiceIssued, getInvoiceCleared, getPayTo, getInvoiceTotal, enumerateLineItems) to read detail.* first, falling back to existing invoiceDetail/paymentDetail/flat.
  3. New accessors: getEmail*, getPaymentTxIds.
  4. Extend transformPayloadToNestedShape to emit detail.* + email.*, with UPDATE-vs-CREATE switch + atomic legacy-key deletes (same pattern as today).
  5. Match/unmatch paths in lib/accounting/transactions.ts maintain detail.payment.tx via FieldValue.arrayUnion/arrayRemove. Project invoices (same DB) batched; coaching invoices (cross-DB) accept the same not-atomic risk we already live with for Sessions.paid etc.
  6. Update writers in lib/projectInvoices.server.ts + pages/api/invoices/send.ts to write the new shape (transform does the heavy lifting).
  7. Migration script scripts/migrate-invoice-doc-shape.ts walks every invoice + quotation + coaching invoice; reads from any of the three legacy shapes, writes the new canonical detail.* + email.*, deletes the legacy keys. Backup-first, dry-run, idempotent, halt-on-error (mirrors migrate-gl-5020-to-5050.ts).
  8. Backfill detail.invoice.drafted + detail.invoice.created on docs that never had them, using the existing fallback chain (updatedAtinvoiceIssued → 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.

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: email top-level for audit/comms; detail.invoiceTotal for 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-0601 exists, but the owner-facing link carried unprefixed 2026-005-0601; the preview API was exact-matching and returned 404. tebs-erl/projects/2026/projects/2026-004/invoice/ERL-2026-004-0508 carried paymentDetail.payTo = "ERL-AWX-HKD" while detail.payment.payTo was 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 by findProjectAcrossDatabases() and matches both prefixed and unprefixed invoice numbers. lib/invoiceDocShape.ts now reads detail.* first but tolerates partial-nested survivors (invoiceDetail, paymentDetail, and older payTo aliases). transformPayloadToNestedShape() also folds invoiceDetail/paymentDetail into canonical detail.invoice/detail.payment on writes so new invoices stop preserving the split shape.
  • Verification: __tests__/lib/invoiceDocShape.test.ts added; targeted Vitest passed; tsc --noEmit passed after linking the existing local node_modules into the clean hotfix worktree; direct patched server-helper verification against Firestore matched 2026-005-0601 → ERL-2026-005-0601 and returned payTo: ERL-AWX-HKD for 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 payTo was restored, but items: [] and total: 0 were returned.
  • Live Firestore findings: the two owner examples still carry item maps outside the canonical path. ERL-2026-005-0601 has top-level lineItems.item1/item2. ERL-2026-004-0508 has an empty detail.lineItems map plus top-level lineItems.item1/item2. The previous accessor treated the empty canonical map as authoritative, so it never fell back.
  • What changed: enumerateLineItems() and getLineItem() now fall back to top-level lineItems when canonical detail.lineItems is absent or contains no itemN entries. transformPayloadToNestedShape() also folds top-level lineItems forward into detail.lineItems so future writes stop preserving the split shape.
  • Verification: targeted Vitest passed; tsc --noEmit passed; direct patched server-helper verification against Firestore returned 2 items / total 2500 for 2026-005-0601 and 2 items / total 3500 for ERL-2026-004-0508.