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.
getInvoiceIssued(data)(lib/invoiceDocShape.ts:68) returnsdetail.invoice.issuedβ{}.toIsoString({})βnull(an empty object has no parseable date).buildInvoiceRecord(lib/projectInvoices.server.ts:339):paymentStatus = invoiceIssuedIso ? 'Due' : 'Draft'β'Draft'.enrichProjectInvoicesWithPaymentDataServer(lib/accounting/invoicePaymentData.server.ts:58): noinvoiceIssuedIsoβ skips bank-tx matching entirely, keeps'Draft',amountPaid: 0.pages/api/projects/index.ts:260-262: the only invoice counts asdrafted, sodrafted === 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 whenif (!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 whenif (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 yieldsnull, so this is mostly defensive) β and, more usefully, havescripts/backfill-invoice-issued-from-suffix.tstreat{}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:
issuedreconstructed fromemail.sentAtbecause that is the serverTimestamp the failedinvoiceIssuedwrite would have produced (samesend.tsupdate wrote both);draftedfrom the doc-id suffix to match the sibling invoice's convention.createdleft 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-0508draftedfix. - What changed: (a) repaired
ERL-2026-004-0508 detail.invoice.drafted {} β Timestamp(2026-05-08)(guarded;issueduntouched) β see "Sibling fix" above; post-fix re-audit shows 0issued={}and 0drafted={}across all 31 ERL invoices. (b) Sharpened "How it got here" to name the mechanism: the{}is a collapsedserverTimestamp()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 theirif (!x)guards read a truthy empty object as "already set." - Blast radius: one more prod data write to a single ERL invoice doc (
draftedonly β cosmetic, no status effect). Still no code/schema change. The 4 benigncreated={}docs are intentionally left.