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(enumDraft | Due | Partial | Cleared). Reports already use the payment map. - Stored field only ever gets
Draft(create, invoices/index.ts:224) andDue(issue, invoices/send.ts ~495).Cleared/Partialare NEVER persisted at runtime (only the one-time migrate-invoice-transaction-links.ts ever wroteCleared). - 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 (enrichpaymentStatus/paid/amountPaidfrom buildPaymentMapFromTransactions at the API/buildInvoiceRecordboundary, as reports.server.ts already does). Replace the matchable-invoices inline copy. AR-aging draft-skip switches storedpaymentStatus === 'draft'→!invoiceIssuedIso. Medium risk; mostly read sites + one query (use bulk map for perf).
Plan (if b) — provisional¶
- Enrich
buildInvoiceRecord(or the API boundary) with derived paymentStatus/paid/ amountPaid from the bulk payment map. - Convert the stale-read consumers to the enriched fields.
- Replace matchable-invoices.ts inline derivation.
- Reduce stored
paymentStatusto Draft/Issued (or drop in favour ofinvoiceIssuedIso). - 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).
Related¶
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.
1a117022026-06-29 — docs(tickets): open I-019 — project #2026-003 stuck "Pending" (invoice issued={} → fixed) (mentions only)fe5c5ea2026-06-29 — docs(tasks): open T-135 — self-describing project/invoice final status (mirror of derived, symmetric on unmatch) (mentions only)3843eea2026-06-30 — feat(accounting): T-135 self-describing project/invoice status mirror (symmetric on match + unmatch) (mentions only)49705cb2026-06-30 — docs(tasks): T-135 → done — self-describing status mirror shipped + verified (mentions only)