Skip to content

Records → Coaching Invoices tab shows none after the coaching-schema migration

Symptom

The Records page Coaching Invoices tab renders an empty list, which the owner suspected was caused by a data migration (the same way the WOPC restructure broke the WOPC tab — see I-011).

Owner, 2026-06-24 (verbatim): "The Coaching Invoices tab are also not showing any coaching invoices most possibly due to a migration of data (similar situation to the WOPC), can you help me look into that as well?"

Root cause (proven against live Firestore, tebs-mel)

The coaching-schema migration (T-079) collapsed Sessions/{id}/invoice/{docId} into a single invoice map field on the session doc. The Records endpoint (pages/api/accounting/matchable-coaching-invoices.ts) reads that map and hard-requires invoice.invoiceNumberif (invoiceNumber) { … } (line ~143); a session whose invoice map has no invoiceNumber is skipped entirely.

Live data: 15 sessions carry an invoice map (under Students/{abbr}/Sessions/*), but 0 of 15 have invoice.invoiceNumber (nor a top-level one). The maps DO carry studentAbbr, sessionOrdinal, issuedAt, lessonRate, etc. — everything except the number. So every coaching invoice is skipped → empty tab.

  • Not RBAC. The endpoint gates on MEL access; the owner is super_admin (passes). (There is a latent RBAC issue for non-admins — the user docs store access as subsidiaryAccess while canAccessSubsidiary reads subsidiaryIds — but that doesn't affect the owner and is out of scope for this ticket.)
  • The rest of the app already compensates for a missing number by reconstructing it (MEL-{year}/{studentAbbr}-{ordinal}(PR)) — see lib/accounting/reports.server.ts:1685 and lib/accounting/derivedJournals.ts:529. The Records coaching endpoint never got that fallback.

Why this is escalated to T-079, not a new task

T-079 (Coaching schema cleanup, owned by Accounting (Infrastructure)https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM) is the migration that owns this shape. T-079's own design table specifies invoice.invoiceNumber as a queryable field (it even specs a composite index on invoice.invoiceNumber for the lookup-by-number paths), so the field being absent on every migrated session is a gap in that migration's done-state — that agent's scope, and the owner is coordinating the coaching/RBAC work with them.

Two clean fix options (for whoever picks it up): 1. Backfill invoice.invoiceNumber onto every session invoice map during the T-079 migration (the design already expected it + the composite index needs it), OR 2. Give the Records endpoint the same reconstruct-when-absent fallback the reports/journals already use.

Not fixed by Records (Infrastructure) to avoid colliding with the in-flight coaching/MEL restructure (the session top level has itself changed — studentAbbr/sessionOrdinal are gone from the top level, now only inside the invoice map — so the exact reconstruction source is a moving target until T-079 settles).

Decision log

2026-06-24 — opened + escalated to T-079 (diagnosed, handed off)

  • Attestation (Records (Infrastructure)): read AGENTS.md; scope-scanned — the coaching migration is T-079's scope, so this ticket cross-links there rather than opening a parallel task.
  • Source: Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

2026-06-24 — FIXED via backfill (Accounting (Infrastructure))

  • Attestation (Accounting (Infrastructure)): read AGENTS.md; appending to this ticket Records (Infrastructure) opened (not editing their prose) + flipping escalated → fixed since the symptom is resolved. The owner chose the backfill (populate the data) over read-path adaptors.
  • Source (edit): Accounting (Infrastructure) · https://claude.ai/code/session_015P6KzVYsQCLgEmUjR9bMwM
  • Root cause refined: the number was never persisted pre-migration — the writer (app/coaching/invoice/[sessionId]/client.tsxgenerateRequestNumber, lib/paymentRequest/types.ts) derives it deterministically from year + studentAbbr + ordinal. The legacy invoice bodies the T-079 migration copied never had the field, so the map lacked it. (The current writer DOES persist invoice.invoiceNumber for new invoices — only the legacy 15 were stranded, so this was a one-time data gap, not a recurring bug.)
  • Fix — deterministic backfill (no adaptors, no deploy): recomputed each number with the exact canonical formula MEL-{year}/{abbr}-{ordinal:03d}(PR) and wrote it to invoice.invoiceNumber on all 15 legacy sessions in prod tebs-mel. Surgical updateMask (only that subfield; rest of the invoice map preserved). 15/15 written, 0 failed; backup dumped first.
  • Correctness proof: the recomputed value matched the pre-existing detail.invoice.number (from the T-077/T-078 detail.* shape) byte-for-byte on the verified sample (MEL-2025/MT-011(PR)) — i.e. the backfill reproduced the real issued number, not a guess.
  • Script committed: scripts/backfill-coaching-invoice-number.py (dry-run default, --apply, backup) — re-run is idempotent (now reports 0 targets).
  • Verified: re-query → 15/15 invoice maps now carry invoiceNumber; all other map fields intact. The Records → Coaching Invoices tab now has data to render (no code change / deploy needed — the endpoint already reads invoice.invoiceNumber).
  • Latent follow-up (NOT bundled, flagged): the reconstruction fallback in lib/accounting/reports.server.ts:~1685 + lib/accounting/derivedJournals.ts:~529 reads sessionData.studentAbbr/sessionOrdinal/sessionYear, which T-079 moved into the invoice map — so it would emit …/UNK-… if ever hit. The backfill neutralises it for these 15 (real field present now), but the fallback is still latent-buggy for any future numberless session. Worth a small hardening pass (point it at the invoice-map fields) — left separate to keep this fix minimal.
  • Update (2026-06-24, Accounting (Infrastructure)): taken on as T-112 — shared helper lib/accounting/coachingInvoiceNumber.ts introduced and both call sites switched to it.
  • Update (2026-06-24, Accounting (Infrastructure)): the second of those two call sites (lib/accounting/derivedJournals.ts) was a client-side parallel mirror that was never wired in live code (zero callers). Deleted along with its sibling lib/accounting/reports.ts under T-079's second postscript — the last legacy Sessions/{id}.collection('invoice') read in the repo is now gone.