Skip to content

Project

Symptom

Owner, 2026-06-29 (verbatim): "The status for project with project number #2026-003 is somehow pending while the invoice has already been issued, and a following WOPC has also been issued to a sub-contractor. Why is it still pending?"

The Projects list shows Pending for #2026-003 (Raw Harmony Limited Β· "η΄„ζ›ΈδΊž") even though its invoice ERL-2026-003-0411 was emailed to the client and the money has actually arrived β€” and a sub-contractor WOPC (ERL-WOPC/2026-008) was issued, signed, and paid out against the same project.

Root cause (proven against live tebs-erl)

The project's single invoice projects/2026/projects/2026-003/invoice/ERL-2026-003-0411 carried an empty object {} in the field the status logic reads to decide "was this invoice issued":

Field on the invoice doc State (before fix) Should be
detail.invoice.issued {} (empty map) a Firestore Timestamp
detail.invoice.drafted {} (empty map) a Timestamp
detail.invoice.created {} (empty map) Timestamp or {} (tolerated β€” see below)
detail.payment.status "Due" (write side; not read for status)
detail.payment.tx ["EWI0Be9eTyAfcNlN1Xqh"] (correct β€” see Payment)
email.sentAt Timestamp(2026-04-23T10:21:15.429Z) (correct β€” the issuance send landed)

How {} becomes "Pending"

Status is evidence-based (T-075/T-076): the stored paymentStatus label is ignored; lifecycle is derived from detail.invoice.issued.

  1. getInvoiceIssued(data) (lib/invoiceDocShape.ts:68) returns detail.invoice.issued β†’ {}.
  2. toIsoString({}) β†’ null (an empty object has no parseable date).
  3. buildInvoiceRecord (lib/projectInvoices.server.ts:339): paymentStatus = invoiceIssuedIso ? 'Due' : 'Draft' β†’ 'Draft'.
  4. enrichProjectInvoicesWithPaymentDataServer (lib/accounting/invoicePaymentData.server.ts:58): no invoiceIssuedIso β‡’ skips bank-tx matching entirely, keeps 'Draft', amountPaid: 0.
  5. pages/api/projects/index.ts:260-262: the only invoice counts as drafted, so drafted === total β‡’ label = "Pending".

The empty {} is truthy in JS, which is also why the dedicated recovery script (scripts/backfill-invoice-issued-from-suffix.ts, guard if (data.invoiceIssued) skip) never healed it β€” it saw a present (truthy) value and skipped.

Control: the sibling invoice proves the diagnosis

ERL-2026-002-0319 (project 2026-002) is settled by the same bank transaction and has a proper detail.invoice.issued = Timestamp(2026-03-19T00:00:00Z) β€” it reads correctly. The only difference between the two docs is the {}-vs-Timestamp issued field. (Both share created = {}, confirming created empty is benign and the reader tolerates it.)

Payment WAS received β€” it's actually "All Cleared", not even just "Due"

The invoice's detail.payment.tx[0] = EWI0Be9eTyAfcNlN1Xqh is not dangling. Bank entries live at accounting/transactions/entries/{id} (NOT a top-level transactions collection β€” an earlier collection-group search looked in the wrong place). That entry exists, status: matched, dated 2026-04-23T23:17Z into ERL-AWX-HKD, total HK$6,000, with gl['4000'] allocations:

gl.4000 = {
  "ERL-2026-003-0411": [{ amount: 3500 }],   ← this invoice, full amount
  "ERL-2026-002-0319": [{ amount: 2500 }]     ← the sibling
}

So $3,500 of the $6,000 client payment is allocated to this invoice = invoice total $3,500 β‡’ deriveStatusFromPayment β†’ Cleared β‡’ project label All Cleared once issued is non-empty.

The WOPC is unrelated to the project status

ERL-WOPC/2026-008 (in tebs-epl payees/JC/wopc/…; expense entry accounting/transactions/entries/Ai2Z5j6CYfnLeElT8R0d, gl['5050'], HK$3,000 to Jeffero Chan, issued 2026-05-05, signed 2026-06-29) is the sub-contractor payout side. Project status is computed purely from the client invoice's issuance + revenue matching; the WOPC neither causes nor could cure the Pending state. Mentioned only because the owner referenced it.

How it got here β€” the {} is an unresolved serverTimestamp() placeholder, then two safety nets skipped it

Every code path that sets these three fields uses a server-timestamp placeholder β€” FieldValue.serverTimestamp() (server) / serverTimestamp() (web) β€” at invoice create (lib/projectInvoices.server.ts:908-909) and at send (pages/api/invoices/send.ts:504). That placeholder is not a value; it's a sentinel Firestore swaps for the real time at commit. If the sentinel is ever carried through a plain-object copy instead of committed directly β€” an object spread ({...payload}) or a JSON.parse(JSON.stringify()) round-trip on the write payload β€” it collapses to an empty object {} (the sentinel has no own enumerable data), and that {} is what gets persisted. The fingerprint is exact: a field the code only ever sets via serverTimestamp() ending up as {}. This invoice pre-dates the 2026-06-20 canonical-doc-shape migration (T-080), so the collapse happened in the older create/send code; the live path writes proper Timestamps today (the paymentStatus β†’ "Due" half of the same send DID land, which is why the doc looked half-issued).

Why nothing auto-healed it β€” {} is truthy. Two safety nets that should have repaired a missing date both test for a falsy value, and an empty object is truthy in JavaScript (!{} is false):

  • The T-080 migration (scripts/migrate-invoice-doc-shape.ts:215,222) backfills only when if (!drafted) / if (!created) β€” so it saw the truthy {} as "already set", carried it straight into the canonical shape, and skipped its own decode-from-doc-id repair.
  • The suffix backfill (scripts/backfill-invoice-issued-from-suffix.ts:102) skips when if (data.invoiceIssued) β€” again truthy {} β‡’ skipped.

So the migration didn't create the {}; it preserved and propagated it while its repair logic was silently bypassed. That's the whole story: a sentinel that collapsed to {} at write time, then two if (!x) guards that couldn't tell {} apart from a real value.

Fix applied (data, one-shot β€” Projects (Infrastructure), 2026-06-29)

One guarded Firestore write on tebs-erl projects/2026/projects/2026-003/invoice/ERL-2026-003-0411 (guarded to only proceed while issued was the broken {}, so it can't clobber a good value):

detail.invoice.issued  = Timestamp(2026-04-23T10:21:15.429Z)   // = email.sentAt β€” the serverTimestamp
                                                                //   the failed send-write would have produced,
                                                                //   recovered from its sibling field in the
                                                                //   SAME update (emailSentAt + invoiceIssued)
detail.invoice.drafted = Timestamp(2026-04-11T00:00:00.000Z)   // = invoice date from the doc-id suffix 0411,
                                                                //   UTC midnight β€” same convention as sibling
                                                                //   ERL-2026-002-0319
// detail.invoice.created left as {} β€” benign; sibling carries it too

Verified by replaying the real status pipeline (buildPaymentMapFromTransactions β†’ deriveStatusFromPayment β†’ the index.ts label rules) against live data after the write: ERL-2026-003-0411: total=3500 paid=3500 β†’ Cleared β‡’ project label = "All Cleared".

UI note: the Projects API builds the payment map fresh per request, but the page itself may hold a short client/React-Query cache β€” a refresh shows the corrected status.

Sibling fix β€” ERL-2026-004-0508 drafted = {} (owner-requested, 2026-06-29)

A full audit of all 31 tebs-erl invoice docs found one other invoice with an empty date field: projects/2026/projects/2026-004/invoice/ERL-2026-004-0508 had detail.invoice.drafted = {}. This was cosmetic only β€” drafted does not feed the Pending/Due/Cleared status (its issued was already a proper Timestamp, so #2026-004 was never mis-statused); the empty drafted would just render the drafted date as N/A. The owner asked to fix it too. Guarded write (only proceeds while drafted is the broken {} AND issued is already a real Timestamp, so status can't be touched):

detail.invoice.drafted = Timestamp(2026-05-08T00:00:00.000Z)   // invoice date from doc-id suffix 0508;
                                                                //   ≀ issued (2026-06-04T07:59:54Z) βœ“
// detail.invoice.issued  unchanged (Timestamp 2026-06-04T07:59:54Z β€” already correct)
// detail.invoice.created left as {} β€” benign

Post-fix re-audit of all 31 tebs-erl invoices: 0 with issued = {}, 0 with drafted = {}. 4 invoices still carry the benign created = {} (the reader tolerates it β€” left as-is). tebs-mel coaching invoices use a different doc layout and are unaffected.

Optional hardening (flagged, not opened as a task)

The defect class is "a truthy-but-empty {} where a Timestamp is expected." Two cheap guards would make the system self-healing against it, if the owner wants them (would be a small T-NNN):

  • Reader: have toIsoString / the lifecycle accessors treat an empty {} as absent (it already yields null, so this is mostly defensive) β€” and, more usefully, have scripts/backfill-invoice-issued-from-suffix.ts treat {} as "missing" so a future suffix-backfill would heal such docs instead of skipping them as "already set."
  • Kept ticket-only for now per AGENTS.md ("a one-off data fix can stay ticket-only") β€” the live write path is already correct and the blast radius is 1 status-affecting doc (now fixed) + 1 cosmetic.

Escalated β€” the structural concern this exposed β†’ T-135

This ticket is the live proof of a broader gap the owner raised: a project/invoice doc doesn't state its own final outcome β€” the paid/cleared state is derived at read time, so an empty/odd stored field (like the {} here) makes the raw data read as the wrong status with nothing to contradict it. The deliberate redesign β€” store a self-describing final status as a reconciled mirror of the derived value, maintained symmetrically on match and unmatch/unlink β€” is tracked as T-135 (opened 2026-06-29; gated on Accounting (Infra)/(Diagnostics) sign-off because it re-opens T-076's derive-only decision). The data fix above stands on its own; T-135 is the durable fix for the class.

Decision log

2026-06-29 β€” diagnosed, data-repaired, verified (ticket-only)

  • βœ… Attestation (Projects (Infrastructure)): read AGENTS.md; scope-scanned the board β€” the closest existing item is I-014 (a WOPC reading "Pending"), but that's a different artifact (WOPC signing-request lifecycle) with a different root cause; no task's scope covers an invoice lifecycle-field {} anomaly, so this is a fresh ticket and stays ticket-only (bounded one-off data fix, current write path already correct).
  • Source: Projects (Infrastructure) Β· https://claude.ai/code/session_01Q2xAAEtBSMfLUGjP1HAA5D
  • Proposed: Projects (Infrastructure). Approved: the owner ("Proceed as 1, 2, 3", 2026-06-29, authorising: (1) the data fix, (2) this ticket, (3) the dangling-tx investigation β€” which found the tx is real and the invoice fully paid).
  • Rationale / why this value: issued reconstructed from email.sentAt because that is the serverTimestamp the failed invoiceIssued write would have produced (same send.ts update wrote both); drafted from the doc-id suffix to match the sibling invoice's convention. created left empty because the reader and the healthy sibling both tolerate {} there.
  • Blast radius for other agents: one prod data write to a single ERL invoice doc; no code changed, no schema changed. Touches the same evidence-based-status read path that Accounting (Infrastructure)/Accounting (Diagnostics) own (T-075/T-076) β€” but only data, the logic is unchanged. The related ERL-2026-004-0508 drafted={} is left for the owner to greenlight.

2026-06-29 β€” sibling cosmetic fix + sharpened root-cause (owner follow-up)

  • βœ… Attestation (Projects (Infrastructure)): read AGENTS.md; no new task β€” this stays under I-019's scope (same {}-in-a-date-field defect class).
  • Source: Projects (Infrastructure) Β· https://claude.ai/code/session_01Q2xAAEtBSMfLUGjP1HAA5D
  • Proposed + approved: the owner ("So what exactly was the problem causing it, and can you fix the cosmetic sibling as well?", 2026-06-29) β€” authorising the ERL-2026-004-0508 drafted fix.
  • What changed: (a) repaired ERL-2026-004-0508 detail.invoice.drafted {} β†’ Timestamp(2026-05-08) (guarded; issued untouched) β€” see "Sibling fix" above; post-fix re-audit shows 0 issued={} and 0 drafted={} across all 31 ERL invoices. (b) Sharpened "How it got here" to name the mechanism: the {} is a collapsed serverTimestamp() sentinel (carried through an object copy / JSON round-trip instead of committed), and both the T-080 migration and the suffix-backfill skipped it because their if (!x) guards read a truthy empty object as "already set."
  • Blast radius: one more prod data write to a single ERL invoice doc (drafted only β€” cosmetic, no status effect). Still no code/schema change. The 4 benign created={} docs are intentionally left.