Skip to content

Derived-as-source-of-truth for match status — stop hard-writing paid/cleared on all matched records

Decision (owner, 2026-06-19)

Go with option (b) — derived is the single source of truth — AND extend it: remove ALL hard-written match/payment status across every matched record type (not just project invoices). One carve-out to verify: transaction.status is a Firestore QUERY INDEX (queried via .where), so it must stay stored even though it's derivable — confirming in the audit before touching anything. Coaching cross-DB writes (tebs-mel Sessions.paid / Payments) need a read-side check before removal. Audit in progress.

Why (owner, 2026-06-19)

An invoice doc's paymentDetail.paymentStatus (and the project paid flag) is set at draft/issue and is NOT updated when a payment tx is matched. The authoritative status is derived at READ time from the tx gl['4000'] allocations, so the two diverge — e.g. ERL-2025-001 collected 2025-01-12 still reads paymentStatus: "Due" / paid: false on the stored doc. Anything reading the stored field (UI, exports, a human opening the invoice) shows wrong data.

Findings (research, 2026-06-19)

  • Derived path already exists & is de-facto SoT: lib/accounting/invoicePaymentStatus.ts — getInvoicePaymentStatus, buildPaymentMapFromTransactions, deriveStatusFromPayment (enum Draft | Due | Partial | Cleared). Reports already use the payment map.
  • Stored field only ever gets Draft (create, invoices/index.ts:224) and Due (issue, invoices/send.ts ~495). Cleared/Partial are NEVER persisted at runtime (only the one-time migrate-invoice-transaction-links.ts ever wrote Cleared).
  • Match/unmatch writes NO invoice status — by deliberate design ("No invoice update needed… derived at read time" / "Eliminates Elephant #5", invoicePaymentStatus.ts:7-8).
  • Stale-read consumers (~6-8 clusters): InvoiceStatusPanel.tsx, ProjectsApp/ ProjectShowApp/projectUtils (paid), InvoiceDetailsDrawer, MatchInvoiceModal, wppCsv, derivedJournals.server.ts:176/520, reconciliation.server.ts:179, isProjectOverdue, clientDirectory/clientPaymentHistory, pages/api/projects/by-id/[projectId].ts agg.
  • Duplicated derivation: matchable-invoices.ts:92-145 re-implements the gl['4000'] read inline (should call buildPaymentMapFromTransactions).

Options (owner to choose — recommend b)

  • (a) Write status on match/unmatch. Smaller-looking change in transactions.ts but re-introduces the dual-source-of-truth the code deliberately removed; write fan-out across 2 create/issue + 4 match/unmatch fns + 2 DBs, partial-unmatch revert is the sharp edge. High long-term risk.
  • (b) Derived = single source of truth (RECOMMENDED). Aligns with existing architecture. Keep stored field ONLY for Draft-vs-Issued (issuance isn't derivable; use invoiceIssued/invoiceIssuedIso). Route the ~6-8 read sites through the enriched record (enrich paymentStatus/paid/amountPaid from buildPaymentMapFromTransactions at the API/buildInvoiceRecord boundary, as reports.server.ts already does). Replace the matchable-invoices inline copy. AR-aging draft-skip switches stored paymentStatus === 'draft'!invoiceIssuedIso. Medium risk; mostly read sites + one query (use bulk map for perf).

Plan (if b) — provisional

  1. Enrich buildInvoiceRecord (or the API boundary) with derived paymentStatus/paid/ amountPaid from the bulk payment map.
  2. Convert the stale-read consumers to the enriched fields.
  3. Replace matchable-invoices.ts inline derivation.
  4. Reduce stored paymentStatus to Draft/Issued (or drop in favour of invoiceIssuedIso).
  5. Derive project paid (all active invoices Cleared) or treat as non-authoritative cache.

Audit (2026-06-19) — reframes the ask

Comprehensive sweep of every hard-written match/payment status. Key correction: invoice paymentStatus / paid / Cleared are NOT written on match in runtime code. Match paths write only the bank-tx doc (gl map + transaction.status). Invoice gets Draft (create) + Due (issue) — real lifecycle events, KEEP. Cleared/Partial only ever came from one-time legacy scripts. So the invoice fix is purely READ-SIDE.

Inventory (classify each): - transaction.status — KEEP-as-index. Used as a Firestore .where(...) filter in 15+ sites (listTransactions core, payment maps, auto-match APIs). Cannot query a derived value. Already kept drift-free via computeTransactionStatus/persistRecomputedStatus. - Project workStatus:'completed' auto-write on match (transactions.ts:2373 match, :2813 sync) — REMOVE candidate (derivable; badges already recompute via _invoiceSummary). But it's arguably a feature, and expense-projects.ts:47 filters on workStatus — owner decision. - Project paid — never written true on match; nothing to remove. - Coaching Sessions/{id}.paid (transactions.ts:3888/:4034) — REMOVE, zero readers (compute.ts uses hasSessPayment, not .paid). Safe. - Coaching Sessions/{id}/payment/{txId} (:3892) — 2 readers (compute.ts hasSessPayment; SessionsTab paymentStatus + "Pay On" date). Removable only after those derive from gl.coachingInvoices (the "Pay On" date must re-source from tx date). - Coaching Students/{abbr}/Payments/{txId} (:3930) — KEEP / migrate-first. 5-7 readers incl. a live onSnapshot (PaymentHistory) and appliedAmount/remainingAmount that have NO gl-map equivalent → not fully derivable. - Receipt metadata.paymentMethod flip (receiptStore.ts:1105) — KEEP-real-state (controls reimbursement-journal derivation; cross-DB).

Revised plan

This PR (safe + clearly intended): 1. Invoice status: enrich the invoice record at the buildInvoiceRecord / API boundary from buildPaymentMapFromTransactions (bulk, perf-safe), route the ~6-8 stale-read consumers (InvoiceStatusPanel, InvoiceDetailsDrawer, ProjectShowApp/projectUtils, MatchInvoiceModal, wppCsv, by-id/[projectId] agg) through it. Keep stored Draft/Due. 2. Replace the duplicated inline derivation in matchable-invoices.ts:92-145 with buildPaymentMapFromTransactions. 3. Remove the zero-reader coaching Sessions.paid write (+ its unmatch revert).

Owner-gated extensions (asked 2026-06-19): - Drop project workStatus:'completed' auto-write (derive instead)? - Migrate coaching session payment-subcollection readers to derive (→ likely its own task; Students/Payments stays — not fully derivable).

T-043 ("Elephant #5" — stored paymentStatus asymmetry; nil-impact-then, this is the UI/export-facing version) · T-075 (sibling GL-code/integrity cleanup).

Log

  • 2026-06-19 created. Owner reported stale stored status. Research mapped derived vs stored vs consumers; recommend option (b) derived-as-SoT. Awaiting owner go/scope before build.
  • 2026-06-19 owner chose (b) + "remove all hard-written match status for all tx". Ran a comprehensive audit (see Audit section): invoices aren't hard-written on match (fix is read-side); real removal candidates are workStatus auto-complete + coaching Sessions.paid (zero-reader) + conditionally the coaching payment subcollection. transaction.status stays (query index); Students/Payments stays (load-bearing, partly non-derivable). Asked owner to confirm the two extensions; building the safe set meanwhile.
  • 2026-06-20 phase 1 SHIPPED in PR #764 (claude/derived-payment-status-yxMLM, on top of #763): buildInvoiceRecord derives paymentStatus from invoiceIssuedIso alone; enrichProjectInvoicesWithPaymentDataServer rewritten to (a) evidence-based draft check (b) bulk payment map (c) accept pre-built map; /api/projects builds the map ONCE per request and shares across project enrichment (N×M → 1 scan); /api/projects/[year]/[projectId]/invoices now enriches; matchable-invoices.ts replaced its inline gl[4000] derivation with buildPaymentMapFromTransactions; zero-reader Sessions/{id}.paid write + revert dropped. tsc + 114-test accounting suite clean.
  • 2026-06-20 phase 2 SHIPPED (same PR #764): owner-approved drop of project workStatus:'completed' auto-write — same dual-source-of-truth concern; the matchTransactionToInvoices auto-complete loop is gone; syncProjectWorkStatuses reduced to a no-op stub so /api/accounting/sync-project-statuses still returns 200 (remove the endpoint in a follow-up). Manual workStatus PATCH unaffected.
  • 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.)

Commit index (backfilled 2026-07-01, best-effort · Coaching (Diagnostic))

Candidate related commits, auto-backfilled from git on main: commits whose message references this task's UID or a PR number it cites. Not verified — this squash-merged history can't yield a precise per-task list, so rows tagged (mentions only) name the task in passing (may be tangential) and untagged work commits may be missing. Treat as a starting point: verify, prune tangential rows, and append any real ones per the AGENTS.md "record every related SHA" policy.

  • 1a11702 2026-06-29 — docs(tickets): open I-019 — project #2026-003 stuck "Pending" (invoice issued={} → fixed) (mentions only)
  • fe5c5ea 2026-06-29 — docs(tasks): open T-135 — self-describing project/invoice final status (mirror of derived, symmetric on unmatch) (mentions only)
  • 3843eea 2026-06-30 — feat(accounting): T-135 self-describing project/invoice status mirror (symmetric on match + unmatch) (mentions only)
  • 49705cb 2026-06-30 — docs(tasks): T-135 → done — self-describing status mirror shipped + verified (mentions only)