feat(expenses): project + phase attribution, tax paid at source (pipeline v2 phase 3) - #442
feat(expenses): project + phase attribution, tax paid at source (pipeline v2 phase 3)#442Clarion1631 wants to merge 109 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Request changes. The green CI suite misses several production and accounting failures.
VERDICT: REQUEST_CHANGES |
…tax deduction Codex PR #442 round 2. Items 6 and 7 are Phase 1 / Phase 0 code, fixed on their own branches. 2. THE BACKFILL WROTE NO COST CODES. Pass (a) filled `projectId`; pass (c)'s predicate still asserted the PRE-fill value, so every legacy row pass (a) touched then matched nothing — an `--apply` that reported success and coded nothing. The predicate now uses the resolved/post-fill project. The stub returned `{count: 1}` without mutating state, which is exactly why this passed: it is now stateful and honours predicates (including SQL's `NULL NOT IN (...)`), and a new test proves one `--apply` codes the row and a second dry run plans zero. Mutation-checked. 3. PHASE SCOPE. "The cost code exists" is not a permission, and five writers were treating it as one. The intake route, the worker's phase loader (it took a projectId and ignored it, offering every company code to the model), QBO suggestions, the manual expense edit and the backfill all now require the code to be a phase OF THAT JOB. 4. TAX POSITION. `installedAtCustomer` no longer defaults from the project — silence is NULL everywhere, including job-folder receipts. Defaulting it true turned "nobody looked at this" into a deduction on a state return, and a job receipt is just as likely to be consumables, tools, fuel or a service; WAC 458-20-102(12)(b) allows the cost of the articles actually RESOLD. The report still counts only an explicit true. New `Expense.taxDeductibleBase` (additive, in schema + migration + apply script) lets a bookkeeper allocate the resold portion of a MIXED receipt, and the expense PUT is the correction path — it accepts `installedAtCustomer` and `taxDeductibleBase`, validated 0 ≤ base ≤ amount − tax against the amount the request LEAVES on the row. `taxAtSource` is unchanged: it stays the factual "tax was charged here". 5. The backfill CSV used a private escaper that only quoted, leaving OCR'd vendor names executable. It now uses csvCell/csvNumber from csv-safe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
VERDICT: REQUEST_CHANGES |
…e item links Codex PR #442 round 3. Items 1, 2 and 8 are Phase 1's and arrive on rebase. 3. [P0] PUT /api/expenses/[id] checked only that SOMEBODY was signed in — no project authorization, no permission. I added tax-return fields to that route, so any authenticated user who knew an expense id could edit the numbers on a state excise return. It now resolves the project and requires access to it (fail closed when there is none), requires `timeClock` to edit an expense at all, and requires `financialReports` on top for installedAtCustomer/taxDeductibleBase — "may edit this expense" and "may decide what the company deducts" are not the same authority. NOTE: DELETE on the same route has the identical pre-existing gap. Untouched here because it predates this PR; flagged for its own fix. 4. The deduction-base invariant is about the RESULTING ROW, not this request's fields. Validating only when taxDeductibleBase was sent meant a PUT that merely LOWERED amount could strand an existing base above the new pre-tax total — the same illegal state through the other door. 5. The item link is scoped to the expense's own estimate or its project. An existence check alone let an edit point at another job's line item, which then feeds the item->costCode fallback. 6. createExpenseCore stored an arbitrary costCodeId and stamped it "manual" (outranking every automated pass) with no project check; receipt-ingest v1 matched a Gemini category against every active company code. Both now go through resolveCostCode + isCostCodeAllowedForProject. RISK, flagged: a change-order expense whose code is not on a phase-eligible estimate item will now be rejected rather than silently miscoded. Correct, but it is a behaviour change on a live path. 7. The backfill's phase check failed OPEN when a project had no mapped phases — absent in exactly the case where we know its phases least. Now requires a positive answer; an unmapped project skips with reason "no-phases". 9. Spec §5 still documented the default-true/false toggle. Rewritten with the as-built tax position, since the mobile repo consumes that section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I found release-blocking correctness and security defects:
VERDICT: REQUEST_CHANGES |
…; validated tax only Codex PR #442 round 4. Items 1, 2, 5 and 6 are Phase 1's and arrive on rebase. 8. DELETE had the same session-only gap PUT had — any authenticated user with an id could destroy any non-QBO expense on any job. Same gate as PUT now: timeClock, resolved project, fail closed when there is none. 3. The correction path could not reach a single row it was built for. PUT is guarded by assertExpenseMutableOutsideQbo, and every pipeline expense carries a qbPurchaseId — precisely the population the tax report reads. Split into a dedicated PATCH that edits ONLY installedAtCustomer, taxDeductibleBase and costCodeId. Those three are ProBuild-only bookkeeping: nothing syncs them to QuickBooks and nothing in QBO overwrites them, so the mutability guard does not apply. amount/vendor/date are refused there at any status. PUT keeps its guard, rejects the tax fields outright (a silent ignore would look like a successful correction), and is now a PARTIAL update — it used to null every field a request left out, so a tax-only edit erased the vendor, date and description. 7. allowedCodesByProject is built from the app's own phase-eligible set (PHASE_ELIGIBLE_ESTIMATE_WHERE, active codes only), not from every coded estimate item. The fail-closed check was looser than it claimed: a code from a draft or archived estimate counted as a phase of the job. The item->code fallback now passes the same gate, instead of bypassing it. 4. Booking persists tax ONLY when buildGroups accepted it. It was storing the raw OCR read even for a check or a nonsense tax >= total, with taxAtSource true — so a misread no human saw could be claimed on an excise return, and amount - taxAmount could go negative. The rejected value now lives only on ReceiptIntake.taxCents, which the report cannot read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
This is not mergeable.
VERDICT: REQUEST_CHANGES |
Bare fetch() has no timeout, so today's Intuit API outage hung every QB call until Vercel killed the function at its maxDuration (60s on the receipt-push create, 120s on the payments cron). qbTimedFetch wraps fetch with AbortSignal.timeout (QB_FETCH_TIMEOUT_MS, default 20s) and rethrows our own deadline as QBTimeoutError carrying the URL path only (no query string, no tokens). A caller-supplied signal is combined via AbortSignal.any and still wins; every other error passes through unchanged. Routed through it: exchangeQBCode, refreshQBToken, qbFetch, qbQuery, plus the five other direct fetches in quickbooks.ts (payment link read, payment delete, invoice delete, purchase CDC, invoice send), the Attachable multipart upload in qbo-receipt-push.ts, and the QBO temp-URL attachment download in qbo-receipt-attachments.ts. Both attachment call sites already treat a throw as a non-fatal "failed:<name>". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Real local http server, not mock.module — mock.module corrupts the require chain on Node 20, which CI pins. Covers: a never-responding server becomes QBTimeoutError; the message carries the path but neither the query string nor a token; success and request init pass through; a connection refusal is NOT relabelled a timeout; a caller's own abort is NOT reported as a QBO outage; QB_FETCH_TIMEOUT_MS drives the default and a garbage value falls back instead of breaking every QB call. Wired into test:unit so CI runs it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A QBTimeoutError from either the token fetch or the purchase create is now
audited as reason "qbo-timeout" and answered 503 {ok:false, retry:true,
reason:"qbo-timeout"}. Non-200 is what makes the Apps Script retry on its
next pass, which is right for an outage — a terminal ok:false would send
the receipt down the email fallback for a failure that fixes itself.
Retrying is safe even if a timed-out create actually landed: it carries a
QBO requestid idempotency key and the docNumber pre-check returns
already-exists.
maxDuration 60 -> 30. Two 20s QB deadlines plus the DB work fit; the whole
point is that we now fail long before the ceiling.
Every other branch is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One summariser (src/lib/pipeline-health.ts) behind two surfaces, so the on-demand check and the digest can never disagree about whether the pipeline is OK: - GET /api/health/pipeline — Intuit status, last QBO purchase sync, last receipt booked, 24h receipt counts by status, bank-ledger high-water mark, 24h error count. - GET /api/cron/pipeline-digest (0 14 * * *, 7am Pacific) — emails the plain-text summary to PIPELINE_DIGEST_TO (default jadkins@) and, when BOT_HEALTH_CHAT_WEBHOOK is set, posts the same text to Google Chat. Sends every morning: a digest that only arrives on failure is indistinguishable from one that stopped running. Verdict rules, unit-tested: a degraded Intuit indicator or any error in 24h fails; an UNREACHABLE Intuit status page does not (a third party's downtime is not evidence of ours); a gap between 48h and 7d fails because traffic was flowing and stopped, while no pushes in 7d is ok with a note because a quiet week is quiet, not broken. Every read degrades to null/unknown on its own rather than throwing — a health check that 500s during an outage tells you nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex blocker 1. fetch() resolves as soon as headers arrive and streams the body afterwards, so a deadline firing mid-body rejected out of res.json() as a raw AbortError — past the wrapper's header-phase catch. The receipt route then classified an outage as a generic transient failure (500) instead of the 503 qbo-timeout it is. The signal stays attached (the deadline must still cut off a stalled body); the returned Response is proxied so json/text/arrayBuffer/blob/ formData translate OUR abort into QBTimeoutError with the same path-only message. Getters and clone() run against the real Response. A caller's own abort during the body read stays a plain error. Codex blocker 7: replaced the AbortSignal.any fallback, which used the caller's signal ALONE and so silently disabled the deadline on any runtime lacking it, with a manual combiner that keeps both live. Tests: headers-then-stall body -> QBTimeoutError; text/arrayBuffer the same; proxy preserves status/headers/clone; caller abort mid-body stays plain; and three cases with AbortSignal.any deleted from scope. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eadline Codex blockers 2 and 3. maxDuration back to 60 on qbo-receipts/create: a healthy push does a lot of SERIAL QBO work (lookups, customer/vendor ensures, account verify, create, attachment upload) and the refresh alone is now allowed 45s. 30 would have started killing legitimately slow pushes. qbTimedFetch is what makes the outage case fail fast; the ceiling is only the backstop. refreshQBToken is not safely retryable — Intuit rotates the refresh token during the exchange, so a timeout may already have burned the stored token while we never saw its replacement. Mitigated two ways: its own deadline (QB_REFRESH_TIMEOUT_MS, default 45s, capped at 50s to stay under the route ceiling), and a distinct diagnosable message naming the stranded-token risk. Still a QBTimeoutError, so route classification is unchanged. Persistence order untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex blocker 4. Two ways the verdict could report OK while knowing
nothing:
1. A failed DB probe degraded to null/0 and sailed into ok:true — an
unreachable database read as "nothing wrong", which is the most
dangerous output this file can produce. Every probe now carries its own
{status: "ok"|"error"}, and any probe error forces ok:false with reason
probe-failed:<name>. The fallback value is never read as evidence: a
failed stuck probe reports probe-failed, not "0 errors", and the digest
prints "unavailable (probe failed)" rather than a number.
2. "No receipts in 7d -> ok with a note" never expired, so a permanently
dead pipeline reported OK forever. Removed. Now ok:false with reason
no-receipts-72h when the last booked push is older than 72h (or there
is none), and the digest prints how long the silence has actually been
so a human decides whether it is expected.
`reasons: string[]` replaces the single note and is empty exactly when ok.
Judgment call, flagged: an UNREACHABLE Intuit status page still does not
by itself fail the check — it is a third party whose downtime is not
evidence of ours, and failing on it would cry wolf on every statuspage
hiccup. It reports status:"error"/indicator "unknown" and is flagged in
the digest body; our real outage signal is the QBTimeoutError count in
`stuck`. Say the word and I will make it hard-fail.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codex issues 5, 6 and 8. The copied `if (process.env.VERCEL_ENV && ...)` shape only enforced the secret where VERCEL_ENV happened to be set — an all-negative env gate that fails OPEN anywhere it is not (self-hosted, container, drifted preview). New src/lib/cron-auth.ts: authentication required everywhere except an explicit NODE_ENV === "development", and a MISSING CRON_SECRET rejects rather than waving traffic through. Comparison is timingSafeEqual with a length check first (it throws on unequal lengths). /api/cron/pipeline-digest uses isCronAuthorized (dev bypass); /api/health/pipeline's Bearer branch uses hasCronSecret, which has NO environment escape hatch, and its staff-session branch is unchanged. Issue 8: vercel.json is strict JSON and cannot hold a comment, so the note that `0 14 * * *` is 7 AM PDT / 6 AM PST (and shifts an hour across DST, since Vercel cron is UTC-only) lives in the cron route's doc comment. The schedule is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… values Codex round 2. 1. raceAbortSignals latches WHICH signal aborted first, in the handler, and attribution reads that instead of inspecting callerSignal.aborted after the fact. The old check lost a real race: deadline fires, caller aborts a moment later, both read aborted by the time the catch runs, and a genuine outage was reported as a caller cancellation (500, not 503). Handlers are named and every listener is removed once the race is decided, so nothing stays attached to a caller signal that outlives the call. 2. The response proxy also wraps bytes() where the runtime has it, and clone() now returns a recursively wrapped Response. Streamed reads via .body/getReader() still surface the raw abort, noted in a comment — no QBO caller streams (the one getReader() in src is on a user-supplied receipt URL with its own controller, not a QBO response). 3. normalizeTimeoutMs floors to a positive integer and falls back to the default on anything non-finite or < 1, so AbortSignal.timeout can never receive a fraction. 4. Comment only: the route's 60s ceiling can preempt a late refresh, so the stranded-token message is best effort. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y-exists Codex gate P1 #1 and #2. parseJsonOrNull replaces `res.json().catch(() => null)` at every QBO body read (vendor create, purchase create, attachment upload, payment create, invoice memo read, invoice send). That catch turned a body-phase QBTimeoutError into "QBO returned no body" — a generic 500 instead of the retryable 503 qbo-timeout, and on the attachment path it could report a successful "attached" for an upload whose response never arrived. Only genuine parse errors resolve to null now; a timeout is rethrown. The already-exists branch no longer returns before the upload. That branch is normally reached because the FIRST attempt's response was lost after QBO committed the Purchase, so its receipt was stranded with no image and every retry took the same early return. It now re-checks the Attachable (by purchase id, filtered to Purchase links) and uploads when missing. Idempotent via a shared deterministic FileName; the result carries attachment: attached | already-attached | skipped | failed:<reason>, which the route now records for both ok branches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…reshness Codex gate P1 #3, #4 and P2 #5. runProbe wraps each health probe in a 5s deadline. A throwing query was already handled; a query that never SETTLES was not, so a wedged database hung the health check until the platform killed it — for the cron that means a silent morning with no digest. A timeout reports {status:"error", reason:"timeout"} and forces ok:false like any other probe failure. Digest delivery is no longer best effort: email and Chat run independently under Promise.allSettled with their own 10s deadlines (one failing or hanging can no longer cost the other), and an email that is not accepted returns 500 {ok:false, reason:"email-not-accepted"} so the failure shows in Vercel's cron history instead of a 200 nobody reads. Chat stays optional. The route gains a DI seam so this is testable. lastReceiptPushAt counts status "created" only. "already-exists" is an idempotent re-push of a receipt created earlier, so counting it refreshed the freshness clock with nothing new in the books — a bot stuck retrying one old file looked like a healthy pipeline indefinitely. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…th; real email check
Codex gate round 3, all four P1s.
1. getFreshQBTokens swallowed a refresh QBTimeoutError and returned STALE
tokens, so the caller spent another full QBO deadline and the 60s ceiling
was still reachable. The policy moved to refreshTokensOrFallBack (what
getFreshQBTokens now calls, same real defaults) and rethrows a timeout.
CHOICE: the stale-token fallback is KEPT for non-timeout failures — that
is the existing intent (an ordinary refresh error can still leave the old
access token valid); only timeouts propagate.
2. /api/health/pipeline was intercepted by NextAuth because the proxy only
bypassed the exact /api/health path, so headless Bearer checks were
redirected to /login. Added an exact-match bypass in both the pattern and
the matcher; the route self-authenticates. No descendant inherits it.
3. email.ts returns {success:true} on a missing RESEND_API_KEY, so the digest
could report emailed:true and 200 while delivering nothing — the failure
disguised as good news. isEmailDeliveryConfigured() fails closed in
production, in the digest route only; email.ts is unchanged for every
other caller. Chat still posts.
4. An attachment QBTimeoutError was turned into failed:QBTimeoutError on an
ok:true response, which the Apps Script treats as FINAL — the Purchase
kept a missing receipt forever and the existing-Purchase recovery never
ran. It now propagates so the route answers 503 and the next pass
attaches. Non-timeout attachment failures keep failed:<reason> + ok:true.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t it) CI's unit job failed where local passed: "a refresh TIMEOUT propagates" resolved instead of rejecting. Cause is module identity, not logic — under Node 20's CJS/ESM interop quickbooks.ts loaded twice, so the class the test threw was not the class quickbooks-payments.ts compared against and `instanceof` was false, sending the timeout down the stale-token fallback. This is not a test artifact: bundler chunk duplication does the same thing in production, and the failure mode is every timeout branch in the codebase silently taking the non-timeout path — the exact misclassification the deadline work exists to prevent. isQBTimeoutError() accepts either the real class or any Error carrying name === "QBTimeoutError" (set as a class field on every instance), and now backs all six checks: parseJsonOrNull, refreshQBToken, refreshTokensOrFallBack, both attachment paths, and the receipt route. Tested against a foreign duplicate class, and pinned against over-matching. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…try transient attachments
Codex gate round 4, all three items.
1. probeQBInvoice flattened everything into {state:"error"} and both loops
continued across up to 200 rows, so six 20s timeouts still reached the
cron's 120s ceiling and the run was killed with nothing reported. The
probe now marks connection-level failures (our deadline fired, the request
threw, or QBO answered 429/5xx) separately from per-invoice errors, and
both loops STOP on the first one, count the remaining rows as `skipped`,
and exit cleanly. A timed-out token refresh aborts the same way. Ordinary
per-invoice errors still just skip that row.
2. Each run now writes one AutomationEvent (kind "qbo-payments-sync",
status ok/error, reason "qbo-unavailable", run counts in detail). Before
this an outage on the money rail left no trace anywhere a human or the
digest would look. pipeline-health exposes lastPaymentsSync and its
errors already count toward `stuck`, so a stalled payments rail turns the
morning digest red.
3. Transient attachment failures were terminal: a 429/5xx upload, a network
error, or a failed Attachable lookup became `failed:<reason>` alongside
ok:true, which the Apps Script treats as final — it stopped resending and
the Purchase stayed unattached. Those now raise QboRetryableError and the
route answers 503 retry:true, so the next pass hits the idempotent
existing-Purchase recovery. A 4xx other than 429 and a QBO Fault stay
terminal (returned as values, not thrown) and still ride on ok:true.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te upload response Codex gate round 5. 1. getQBPayment failures were caught as ordinary row errors, so several settled invoices could each still burn a 20s deadline after a good probe. Both loops now run through one shared, exported runQboRowLoop: ANY connection-level failure from ANY QBO sub-call in a row (probe, payment detail, anything added later) stops the run, counts the rest as skipped and exits. getQBPayment raises on 429/5xx instead of returning null. Tested by driving the REAL loop with an injected fake QBO client, not a duplicated decision helper. 2. Only a timeout marked a run failed, so QBNotConnectedError and settings-store failures emitted status=ok and the digest stayed blind. classifyPreflightFailure covers every branch; the event now keys off result.runFailed with its own reason. 3. An empty, truncated, or HTML 200 from the Attachable upload fell through to "attached" for a file QBO never stored. Intuit's schema says the response carries an Attachable or a Fault, so a real AttachableResponse[].Attachable.Id is now required; anything else is retryable. 4. lastPaymentsSync null or older than 26h is now reason "payments-sync-stale" and ok:false, and the probe only counts runs sourced "cron" — on-view runs log source "view", manual "manual", so neither can disguise a dead hourly job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gration, CAS Codex round 8. Rebased onto Phase 1 head e3da4a6 first. 1. /start and /finalize enforce the SAME capture rules as the inline door, through one shared validateCapturedPhase: a costCodeId is checked for existence, active-ness AND membership of the named project, and is refused outright when there is no project to check it against. Both persist installedAtCustomer as a tri-state with no default. The two-step path had no validation at all, so a crew phone could pin any active company code to a receipt on any job — and booking copies a captured code onto the Expense with provenance "capture", which no automated pass may correct. 2. bookReceipt's alreadyExists path FILLS the Phase 3 fields it left blank (projectId, phase, provenance, tax, installedAtCustomer) and only the blanks: costCodeSource capture/manual and an already-answered installedAtCustomer are a human's and are untouchable. If the existing Purchase is on a DIFFERENT job than the intake claims, nothing is filled and the row parks as NEEDS_REVIEW "attribution-conflict" — filling would be guessing which job is right, overwriting would move real money between jobs. 3. schedule-core (both spots), automation-events, the ai-review route and the manager receipt queue now label and roll up by the resolved job. 4. The tax PATCH writes under a compare-and-set on the values its validation depended on (amount, taxAmount, taxDeductibleBase) and answers 409 on a miss; the sync's write does the same and RE-PLANS once against a fresh read. Mutation-checked: dropping the predicate loses a bookkeeper's correction. 5. PUT refuses all five tax fields by name with the field in the response — a silent drop looks like a successful correction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ooking fill Rebased onto Phase 1 head 8297b46 (sealed uploads, SHADOW_QUARANTINE, claim fencing, project-scoped loadPhases — Phase 1's version kept for every shared hunk, and its stricter loadPhases supersedes mine). 1. E2E was RED because I made `companyTimeZone` a REQUIRED dependency, so every caller that builds its own set threw. It is optional now, defaulting to the shared resolver — a dependency exists to be overridden, not re-stated. 2. When a gross drop invalidates only the ALLOCATION, the row is flagged as well as cleared. A silent null still read as a valid deduction: installedAtCustomer was untouched and a null base means "the whole pre-tax total", so the report would have claimed MORE than the human allocated. 4. The already-booked fill is now one guarded `updateMany` per field (`costCodeId IS NULL`, `installedAtCustomer IS NULL`, source not capture/manual) instead of read-then-write. The read is inside the transaction but a PATCH can still land in the gap — and that PATCH is exactly the authority the fill must not overrun. Regression covers it. 5. /finalize on a non-STAGING retry applies late costCodeId/installedAtCustomer only where the row is still unanswered, and returns 409 late-fields-conflict when the retry carries a DIFFERENT answer to one already recorded. 6. The manager receipt queue selects the direct project and labels through resolveExpenseProjectLabel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t-scoped labor
Codex round 9. Items 5 and 6 are start/finalize — Phase 1's files.
1. DELETE already authorized on the resolved job; a divergent-attribution test
now pins it (the job it LEFT confers nothing, the job it is ON does).
2. The tax PATCH's CAS names projectId, estimateId and a new `Expense.updatedAt`
row version. Access to the row was granted because of the project it was on,
so a re-attribution landing in the gap means the permission check that let
the request through was answered about a different job — 409 rather than a
write. The column is added nullable, backfilled, then SET NOT NULL, so no
DB-level default is left that `@updatedAt` does not declare.
3. New src/lib/expense-lock.ts: ONE `pg_advisory_xact_lock('expense:'||id)`,
taken inside their transactions by the QBO sync, the tax PATCH and the
booking fill. Per-column CAS stops a lost update but not a torn one, and the
tax invariants span columns. The predicates stay — the lock orders writers
that take it, the predicate protects against one that does not. After a CAS
miss the sync re-plans and re-CASes; a still-contended row is LEFT ALONE,
never unconditionally written, because the sync's facts survive to the next
run and a discarded human answer does not.
4. The booking fill takes the same lock and pins `projectId` in every guarded
predicate, so a re-attribution in the gap makes it match zero rows instead of
writing a phase and a tax answer onto a job they were never about.
7. Labor coverage resolves its item fallback through the project-scoped map the
expense side already used.
Test fakes had to learn two real behaviours: the advisory lock is RE-ENTRANT
within a transaction (serialising every call deadlocked the second one), and
the apply-script parity test now selects the backfill by what it writes rather
than by being the first UPDATE.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…kfill ordered Rebased onto Phase 1 head 8b332c7 first. Its shared reconcileLateFields and claim fence are kept; Phase 3's late fields fold INTO that helper (it already carried a note saying installedAtCustomer would land there) rather than being re-added alongside it, and a late costCodeId is validated against the job it is claimed for before reconciliation. 1. `Expense.updatedAt` gets `DEFAULT now()` in the migration, the apply script and Prisma (`@default(now()) @updatedAt`). The apply script runs against production BEFORE the build that knows the column, so for that window the OLD app is still inserting Expenses without it — NOT NULL with no default would have failed every receipt, manual entry and QBO-sync insert until the deploy landed. Order is asserted: nullable, backfill, DEFAULT, then NOT NULL. 3. `deleteExpense` (the single-expense server action) authorizes on the resolved job. It had its own copy of the bug the DELETE route had, and the earlier divergent test never touched this path. The new test does, and is mutation-checked: restoring the estimate read fails 2 of its 6 cases. 5. The backfill's cost-code writes run under the shared per-expense advisory lock and CAS on the row version their plan was computed from. This script's plan is the stalest of the four writers' — built for every row up front, applied over minutes — so it was the one still racing. A miss is counted and reported, never retried: the decision was about a state that no longer exists. Rows the project pass just filled are exempt from the version check, or the backfill would miss on a version it bumped itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the lock Rebased onto Phase 1 head 85d6af5. Its finalize authorization and exact-state late-field fencing are kept whole; Phase 3's `installedAtCustomer` folds into their reconcileLateFields and their authorizeLateFields replaces my duplicate phase check. 1/2. Confirmed on this branch and pinned by a new test: a late `projectId` at /finalize is refused when the session caller cannot see that job, the check is on the LATE project rather than the row's existing one, a late phase must belong to the EFFECTIVE project, and a secret forwarder skips the per-user check but not the phase rule. 3. The two data scripts imported TypeScript from src/, which plain `node` only resolves on 22.6+. They now run under `node --import=tsx` — and are renamed .mjs -> .ts, because tsx hands a .ts module to an .mjs file as CJS and the named imports fail outright. A `--help` path (no DB, no env) doubles as the CI smoke test that the documented command actually loads the import graph. @ts-nocheck keeps them exactly as unchecked as they were as .mjs; typing them properly belongs in its own change. 4. The backfill's cost-code pass now RE-READS each row under the per-expense lock and re-checks eligibility before writing, carrying the post-fill version into the CAS. The previous fix exempted rows the project pass had touched from the version check, which traded one hazard for another: an exempted row had no version guard at all. Tests cover a bookkeeper coding the row and a re-attribution, both mid-run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cost codes Codex round 12. Item 2 (finalize count/reauthorize) is Phase 1's. 1. The QBO suggester now reads the vendor and description off the PERSISTED row instead of taking them from the payload the sync just processed. Those differ exactly when the upsert refused the payload: an out-of-order webhook carries an older SyncToken, `isIncomingQboSyncTokenCurrent` correctly rejects it, and the suggestion then coded the row from a version of the purchase the database had just thrown away. The write is fenced on that row's `updatedAt` AND `qbSyncToken`, so a newer sync landing in the gap wins. 3. The backfill's project fill CASes on `projectId IS NULL AND estimateId = plannedEstimateId` — NULL alone did not say the derivation was still valid, and a row re-pointed at another estimate would have been stamped with the old estimate's project by the very pass that exists to get attribution right. The cost fill re-RUNS `planBackfill` over the freshly-read row under the lock and only writes if the answer is unchanged: eligibility was never the whole dependency, the vendor/description/item feed the decision too. 4. `suggest-expense-cost-codes.ts` loses `--apply` entirely. It was a second writer of `costCodeId` with none of the backfill's guarantees. Report-only now, through the canonical resolver and the project's phase-eligible codes, with csv-safe output; a test fails if a write or the flag returns. Test fakes learned one more real behaviour: `findMany` returns a SNAPSHOT, so a test can model "the row changed after the planner saw it". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Phase 1's helper now returns `{status, body}` rather than a NextResponse, so
the assertions read `.body` instead of awaiting `.json()`. Behaviour asserted
is unchanged: the late project is the one authorized, and a late phase is
checked against the EFFECTIVE project.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A re-sync only asked for review when the new gross BROKE an invariant (tax above the amount, or an allocation that no longer fits). An ordinary change breaks nothing and is just as capable of invalidating a human's answer: a $412.10 receipt re-syncing as $498.30 leaves $34.06 of recorded tax describing a purchase that no longer exists, and an installed-at-customer "yes" describing a different basket of goods. So any cent-level movement in the gross on a CLASSIFIED row (a tax amount, an installed-at-customer answer, or a hand allocation) now sets needsTaxReview. The classification itself is kept — it may still be right — and the report already excludes flagged rows, so the filing waits for a person rather than claiming a figure nobody re-checked. Unclassified rows are untouched, or every re-synced purchase would bury the ones that matter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The route validated `Number(body.amount)` and persisted `parseFloat(body.amount)`. Those disagree: "10junk" validates as NaN, which passes every check that is not a comparison, and then persists as 10 — a $207.74 receipt quietly becoming a $10 one, with the deduction-base ceiling computed from a number nobody ever stored. `body.amount ? ...` also dropped a legitimate 0, so a receipt could not be zeroed. Now: one parse, rejected unless finite and >= 0, and that same value is both what the ceiling check uses and what is written. An absent key still means "leave it alone". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ction DDL
Three round-13 items on the booking and rollout paths.
taxSource ("ocr" | "manual"): a bookkeeper who decides a receipt has NO sales
tax leaves a null taxAmount, which is indistinguishable from "nobody has
looked" — so the next booking wrote an OCR figure straight over their answer.
Booking stamps "ocr", the tax PATCH stamps "manual", and both fills now refuse
to touch a manual row (with the explicit NULL branch SQL requires, or every
legacy row would be excluded instead). PUT refuses the column by name.
The existing-Expense fill takes the per-expense lock BEFORE the read it decides
from, and re-reads the attribution after its guarded writes: an Expense that
was re-pointed at another job while the fill ran now throws, which rolls the
fills back with it, and the row parks as attribution-conflict rather than being
marked BOOKED against somebody else's job.
The apply script adds updatedAt WITH its default in one statement (a bare
column left a window in which the OLD build's inserts landed NULL after the
backfill had already passed, so SET NOT NULL could lose that race), keeps the
old-shape repair as no-ops, and runs the whole DDL in a single transaction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two stale-input paths the guards did not cover. The project fill pinned `estimateId`, which proves the row never left its estimate and says nothing about where that estimate now lives: an estimate moved to another job between the plan and the write stamped every one of its expenses with the OLD project. The predicate now joins the estimate in the same statement, so the derivation has to still hold at write time. The cost-code fill re-planned under the lock, but against the minutes-old snapshot of item links and job phases — so an item re-coded, or a phase removed from the job, after the snapshot was invisible, and the pass wrote a code that was only correct in the past. It now re-reads that one item and that one job's phase list inside the same transaction, applying the same eligibility rules the snapshot query does. Tests cover all three: estimate moved, item re-coded, phase removed. Each was verified to fail with the corresponding guard removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
needsTaxReview means a re-sync moved the gross out from under a whole tax classification, so any tax edit clearing it let a bookkeeper answering "yes, installed at customer" silently certify a tax amount and a deduction split they never looked at, and the row went straight back into the excise report. Clearing it now takes `taxReviewAck: true` carrying both taxAmount and taxDeductibleBase (installedAtCustomer stays optional: a null reads as unanswered and cannot overstate a deduction). A partial correction is still accepted and simply leaves the flag up. The modal shows a confirm checkbox on a flagged row and sends both figures with the ack, changed or not. Deletion also retires `taxSource` and counts it in the already-retired check, so a deleted purchase that a person had classified stops reporting a change forever while still claiming their provenance. Spec and PR body corrected: the backfill is `node --import=tsx scripts/backfill-expense-attribution.ts`, and the correction path is PATCH. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`--help` is the CI smoke test that this file still loads, and it ran a PrismaClient constructor at module scope — which throws without DATABASE_URL, so the check failed on CI while passing on any machine with a .env. The client is now built inside main, after the help branch returns. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ll derives from Three round-15 items. An OCR tax read only had to clear "less than the total" to be booked as tax paid at source, so $90 of tax on a $100 receipt — a decimal point in the wrong place — went onto a state excise return as a $90 deduction nobody looked at. The bound the bookkeeper's PATCH already enforced now lives in expense-attribution.ts and both writers use it. The remedies differ because the situations do: PATCH refuses the request, booking cannot (the Purchase is already in QuickBooks), so it stores NULL, flags needsTaxReview, and keeps taxSource "ocr" — a machine looked and got an answer a person must replace. The sync's classification test now counts taxSource "manual" as evidence, and reads the column. A bookkeeper who decides a receipt carries NO tax leaves every other signal null, so that row — a human's explicit answer, now describing a different gross — was the one row a re-sync said nothing about. The backfill share-locks the rows its answers are DERIVED from (the estimate, the linked item, the job's phase rows) before taking the per-expense lock and reading. A predicate can catch a row that moved before the write; it cannot stop one moving during the read sequence that decides what to write. The project fill is now one expense per transaction so it can hold that lock, and a row re-pointed at an estimate or item the locks do not cover is skipped rather than written. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lidation Four round-16 items. Provenance is per decision. `taxSource` governs the two tax FIGURES and is stamped "manual" only when the PATCH actually carries one, so answering the installed-at-customer question no longer claims a person supplied tax numbers, and clearing the tax back to blank leaves the column alone (a blank is an absence, not a decision, and locking on it would freeze the row out of the pipeline forever). `installedAtCustomer` is its own evidence: non-null means answered, booking fills only a null, and a manual tax figure no longer blocks a capture from answering a question nobody touched. The phase is re-asked a third time INSIDE the booking transaction, after share-locking the job's phase rows. The two earlier checks hold nothing still, so a phase deleted mid-write still reached job cost. A code that is no longer a phase parks the row: booking it posts money to a line the job does not have, booking it uncoded silently discards a captured phase, and the Purchase already exists. The lock helper is shared with the backfill so the two writers of a cost code take it in one order. Amounts are SIGNED. A refund is a negative expense and its tax comes back with it, so the rule is direction and magnitude: the tax matches the sign of the amount (zero always allowed) and never exceeds it. Encoded in the shared bound, in the PATCH (a sign mismatch is a 400 naming the reason, never a constraint violation surfacing as a 500), and in the CHECK, which is now dropped and re-added by name so a database carrying the old refund-refusing definition is corrected. `taxAtSource` tests for zero rather than "not positive", or every credit was refused. The company-financials all-time ranking is two grouped sums again - direct rows, and legacy rows resolved through their estimate - merged in memory. Correct but unbounded is still unbounded: it was fetching every expense ever, for five numbers. The two predicates are disjoint by `projectId: null`, which is the same precedence the row-by-row resolver applies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…actional invariant Four round-17 items. ONE SIGNED MODEL FOR CREDITS. A return or vendor credit is a negative expense whose tax comes back with it, and half the pipeline still assumed money only goes out. The tax report summed `taxAmount > 0`, so every credit was dropped and the deduction went on claiming tax that had been refunded; the sync's invalidation compared `existingTax > amount`, and -4 > -50 is true, so it retired the classification on every credit it saw; the deductible-base CHECK demanded `base >= 0`, which made a credit unallocatable. All three are now sign-and-magnitude: the tax and the allocation point the way the money does and never exceed it. A reduced refund that can no longer carry its recorded tax is flagged for review with the tax nulled, never an aborted import. The base CHECK is dropped and re-added by name, in the migration, the apply script and the blind-spots snapshot. PROVENANCE. A PATCH carrying `taxAmount: null` is a bookkeeper saying there is no sales tax on this receipt, so it stamps `taxSource: "manual"` and booking will not write an OCR guess over it; an OMITTED key still leaves the column alone. `taxReviewAck` is now accepted only with both figures present, non-null and coherent with the amount, so an acknowledgement cannot certify an empty row back into the excise report. FINALIZE. The row read selects `installedAtCustomer`, the merge treats it as a captured field, and the publish CAS fences on it. It was the one path that could silently replace a tax answer: the merge saw no stored value, so a late `false` overwrote a captured `true`. PHASE VALIDITY IS NOW A TRANSACTIONAL INVARIANT. `assertPhaseOfProjectTx` locks Project, Estimate, EstimateItem and CostCode FOR SHARE in one fixed order and then answers on the caller's own transaction. Booking, the manual PATCH, finalize and the QBO suggester all use it, so an estimate archived or reassigned, or a cost code deactivated, between the check and the write can no longer be written into job cost. The phase data source also stops handing back deactivated codes, which the validation path had been trusting. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ariant everywhere Six round-18 items. taxSource is now four explicit states — null (unreviewed), "ocr", "manual" (a person's figure) and "manual-none" (a person saying this receipt has no sales tax) — documented in the spec with a table, and composed through taxNotHumanDecidedWhere() rather than tested for by hand. An acknowledgement needs the taxAmount key: a figure, or an explicit null. Omitting it is a 400, because a request that says nothing about tax has nothing to certify. A blank deduction base is no longer a null with a remembered meaning: the server computes and stores amount - tax, sign intact. The modal derives taxAtSource from the figure through the shared rule, so a refund's negative tax no longer stores "no tax here" and drops the credit out of the excise report. ReceiptIntake now records WHO captured a phase: "user" for a signed-in person, "machine" for a shared-secret forwarder, derived from the caller at every door and never read off a body. Booking copies the distinction, so a Drive folder name books as a correctable "machine" phase instead of borrowing the authority of a person who picked it. Every Expense writer that sets a cost code now runs assertPhaseOfProjectTx inside its write transaction — the POST route, the legacy PUT, createExpenseCore and the Drive receipt ingest join booking, the PATCH and the QBO suggester — and a tripwire test fails when a new writer appears without it. The rollout script no longer swallows the CompanySettings query: only an ABSENT row falls back to the app default, and an unreadable one aborts rather than re-anchoring a whole table into a zone nobody chose. The re-anchor is idempotent by marker (attributionAnchoredAt), because the time-of-day predicate is not one for a company configured as UTC. The already-booked recovery fills a null receiptUrl and never replaces an existing one: a receipt nobody can open is the difference between a defensible deduction and a number in a spreadsheet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… is re-resolved under lock
Five round-19 items.
A blank tax field meant two different things and the payload could not tell
them apart. It now says: `{ taxAmount: null, taxKnown: false }` is "nobody has
read it yet" — it stamps no provenance, keeps a review flag up, and is refused
as an acknowledgement (TAX_UNKNOWN); `{ taxAmount: null, taxKnown: true }` is
"I looked, there is no tax", recorded as manual-none. The modal asks which with
a pair of radios when the field is empty, and refuses NaN or Infinity before
serializing (JSON turns both into null, which the server would otherwise read
as a deliberate "no tax"). The server refuses them too, because the modal is
one caller of many.
On a FLAGGED row an acknowledgement now needs both taxAmount and
taxDeductibleBase present — each a figure or an explicit null. The flag says
the whole classification is in doubt, and certifying one figure while staying
silent about the other is the half-answer it exists to prevent.
An expense with no projectId of its own answers through its estimate, and that
estimate can be moved to another job mid-request. PATCH, PUT and DELETE now
share-lock the estimate inside their transaction, re-resolve the job, re-check
the actor against THAT job, and carry it in the write predicate — so a row
that moved is refused (403 when the actor may not touch the new job, 409 when
the row moved underneath them) rather than written under a stale permission.
The QBO suggester does the same, so its phase check and its write agree about
which job they are for.
The new-expense form offers this job's phases instead of every active cost code
in the company. The server refused everything else anyway; the picker was
inviting a refusal.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The diff is not ready. I found five correctness and security blockers:
I applied the repository’s Supabase/Postgres guidance when evaluating transaction locking and schema invariants. I could not rerun the suite because the workspace shell sandbox failed before command execution; these findings come from static review of the exact PR head. VERDICT: REQUEST_CHANGES |
…one pair Five round-20 items. `taxAtSource` was a second writable column saying what `taxAmount` already says, so the two could disagree: true with no amount is a claim about nothing, false with $16.55 on the row is a deduction silently dropped from the excise return. It is now derived server-side, refused outright when a client sends it (the modal no longer does), and a database CHECK makes the disagreement unrepresentable — normalised first, since a CHECK cannot be added to a table that already violates it. "Tax unknown" is a RETRACTION, not a no-op: it clears the provenance back to null along with both figures. A row left carrying "manual" with no human answer behind it locks the pipeline out of that receipt forever. `projectId` and `estimateId` are one fact said twice, and all three creators wrote them from two reads taken far apart. The expense POST route, createExpenseCore and booking's existing-expense fill now share-lock the estimate inside the write transaction, re-read the pair, revalidate that the line item is still on it, and write both halves together — booking parks as attribution-conflict rather than filling a project alongside a foreign estimate, which is an expense on two jobs at once. Single and bulk expense deletion and change-order tagging do the same locked re-resolve the API DELETE does, one row per statement so each carries its own attribution predicate: a batch authorized row by row was being mutated as a set with nothing holding those answers still. The backfill share-locks the candidate cost code and re-asserts `isActive` immediately before the update. Retiring a code is the company saying "stop putting money here", and this pass is the one writer with no human behind it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
VERDICT: REQUEST_CHANGES
|
…tale checks answer about the locked job
Codex round 21, findings 1-5.
1. FOUR writers still stamped `projectId` from a value read before their
transaction: the QBO create and its catch-up fill, the AI parse, the Drive
ingest, and the receipt booking's create path. Each resolved an estimate's
job, did real work (a QBO round trip, a model call, a phase lookup), and
then wrote both columns — so an estimate moved in that window produced an
expense claiming two jobs at once, which `resolveExpenseProjectId` and every
join through the estimate answer differently.
All four now re-read the pair inside the write transaction through
`lockEstimateAttribution`. The locked answer is the authority: the QBO paths
write it (an unattributed row is honest, half a pair is not), the booking
and the two routes REFUSE on a disagreement rather than guessing. A new
tripwire in tests/expense-attribution-pair.test.ts fails when a future
writer of the column appears without the re-read; its `data: { … }`
detection matches braces rather than a character budget, because a fixed
window read billing-core's invoice stamp as an attribution write.
2. The legacy PUT re-resolved the job under lock and then validated against the
PRE-transaction one — the single value that re-resolve exists to distrust.
Both checks now answer about `lockedProjectId`: the phase invariant, and the
item link, which is re-asked inside the transaction through the new
`itemBelongsToProjectTx` (it locks the item AND its estimate, because the
link can break from either end). The pre-transaction item check stays as a
fast fail and is documented as holding nothing.
3. The apply script cannot close the live-write gap in one pass — it runs
BEFORE the new build, and the old build keeps writing NULL-projectId,
UTC-midnight rows until it drains. The two backfills are now an exported
subset with a POST-DEPLOY marker and a `--post-deploy` mode that re-runs
only them. Both were already idempotent by predicate (`projectId IS NULL`;
`attributionAnchoredAt IS NULL` AND a 00:00 time-of-day) — which is exactly
the shape of the stragglers — and a test pins that they are a subset of the
main run rather than a second copy that can drift.
4. The coverage metric keyed its item map by item id alone. The cross-job gate
was applied when an entry was ADMITTED and then any other row pointing at
that item read the same entry back — including the corrupt cross-job row the
gate exists to exclude. Keyed by `projectId:itemId` now, so the scope is part
of the lookup and the false positive is unrepresentable.
5. The expenses tab has two doors — the page's first render and the client
refresh after a save — and only the first resolved `receipt-intake://`
references to signed URLs. Every pipeline-booked receipt link died on the
first tax edit. Both go through one `resolveReceiptUrls` helper now.
Finding 6 (inline intake bypasses decideSource) is Phase 1 code and is routed
to that builder.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Request changes. The diff still has correctness failures in attribution, tax review, and rollout safety.
Local test execution was unavailable because the command sandbox failed during initialization; this review used the exact matching PR patch and head sources. VERDICT: REQUEST_CHANGES |
CI doesn't set DATABASE_URL so any test that imports a Prisma-dependent module fails with "DATABASE_URL is not set". Add the standard mock at the top of both attribution test files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…, and make the post-deploy pass mandatory Codex round 22 (REQUEST_CHANGES), 2 findings: 1. upsertQboExpense's create and legacy-fill paths wrote whatever project lockEstimateAttribution() returned, even when it disagreed with the project the purchase was matched and shaped for. write.projectId / plan.fill.projectId describe the job a purchase's vendor/description/ amount were classified against; silently swapping in a newer, disagreeing lockEstimateAttribution() answer landed a purchase classified for one job on another job's books. Both paths now compare the two and refuse the attribution write on a mismatch (skip-with-warning for create, skip-the- fill-only for the update path, since its tax/amount reconciliation is independent of attribution) — the next sync re-matches against the estimate's current project instead. tests/qbo-expense-sync.test.ts: the two tests asserting the old trust-the-lock-unconditionally behavior are rewritten to assert the refusal instead. 2. apply-expense-attribution.mjs's --post-deploy pass (closes the live-write gap while the old build drains) was documented as an "and again" nice-to- have. Its header now says plainly that skipping it leaves rows permanently unattributed with no error, and the script now verifies BOTH backfills report zero remaining rows (previously only the projectId one was checked) — the re-anchor gets the same "0 expenses left at UTC midnight" assertion. PHASE-3-ATTRIBUTION-SPEC.md goal 1 now states the post-deploy pass is mandatory and names the exact verify lines to look for. npm run build: 0 errors. tsc --noEmit: clean. test:expense-attribution (303 tests) and test:qbo-expense-sync (116 pass, 2 skipped — no local test DB) both green. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I applied the repository’s Supabase/Postgres locking guidance. Four changes are required:
VERDICT: REQUEST_CHANGES |
…d instead of coercing them
Codex round 22 adversarial review, 2 findings fixed:
1. PATCH /api/expenses/[id] used `Number(value)` on taxAmount and
taxDeductibleBase (the ack-review `coherent()` check, and both write
paths), which silently coerces JSON garbage into a certified figure:
`Number(false)`, `Number("")`, and `Number([])` are all `0`. A
malformed or buggy client could book a $0 tax answer nobody actually
entered. All four sites now require `typeof value === "number"` before
touching it, and reject anything else with a 400.
2. The costCodeId write path only checked `typeof body.costCodeId ===
"string"` to decide the value, silently falling through to `null`
(clear the cost code) for any other type — a number, boolean, array,
or object. That treats a malformed request as an intentional
attribution wipe. Now rejects a non-string, non-null costCodeId with a
400 instead of writing it.
3 new regression tests cover the coercible-to-zero values, the ack path's
own coherent() check, and the costCodeId type guard.
Also fixes docs/plans/PHASE-3-ATTRIBUTION-SPEC.md, which said
taxDeductibleBase is optional when acknowledging a review — the code has
always required both taxAmount and taxDeductibleBase together on a
FLAGGED row (the flag means the whole classification is in doubt, and
the two figures are the whole classification). The spec now matches.
2 findings from the same review are defended, not fixed:
- the new Project->Estimate->EstimateItem->CostCode lock order in
phase-invariant.ts is new infrastructure this PR introduces for the
attribution path; pre-existing callers (e.g. expense creation's
lockExpense) predate it and are out of scope for this PR.
- the post-deploy procedure finding was already addressed in dc7bf48,
which made the post-deploy pass explicitly mandatory in both the spec
and the script's own header/verification output.
npm run build: 0 errors. tsc --noEmit: clean. test:expense-attribution:
306 pass (303 + 3 new), 0 fail.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ute.ts The previous commit's edit tool rewrote route.ts through a full-file string replacement, which normalized the file's pre-existing mixed CRLF/LF line endings (a known repo issue — see MEMORY.md "Apply script vs migration parity") to uniform CRLF. That turned a 4-site, ~40-line logic change into a 238-line diff with no functional difference, which would have obscured the actual fix from review. Reconstructed the file byte-for-byte against HEAD~2 (before either of these two commits), keeping every unchanged line's original EOL exactly as it was and using CRLF only for the genuinely new/changed lines. The diff against HEAD~2 is now the real ~40-line change. No logic changed. npm run build: 0 errors. tsc --noEmit: clean. test:expense-attribution: 306 pass, 0 fail. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
Receipt Pipeline v2, Phase 3. Spec:
docs/plans/PHASE-3-ATTRIBUTION-SPEC.md(committed here).What and why
Expensehad noprojectId. It reached a job only through its requiredestimateId, so eleven money-path readers each hand-rolled{ estimate: { projectId } }, and a receipt could never be born knowing its job. Phase 3 gives an expense its own attribution and makes one module the answer to "whose job is this, and which phase?".Expense.projectId(+FKSET NULL, +index),taxAmount,taxAtSource,taxSource,installedAtCustomer,taxDeductibleBase,needsTaxReview,costCodeSource,costCodeConfidence,updatedAt;ReceiptIntake.taxAtSource+installedAtCustomer.projectIdis backfilled fromestimate.projectIdby the same idempotentUPDATEin both the apply script and the migration.capture= a person >ai/backfill> null). Nothing but a human edit rewrites acapture/manualcode.costCodeSourceis never read off a request body: provenance is something the server observes, not something a client asserts.taxSourcerecords who decided the tax columns (ocrfrom the pipeline,manualfrom the PATCH). Booking never writes over amanualdecision, including the decision that a receipt has NO tax, which is a nulltaxAmountand otherwise indistinguishable from "nobody looked". A QBO re-sync that moves the gross on a classified row setsneedsTaxReview; clearing that flag takes an explicittaxReviewAckcarrying bothtaxAmountandtaxDeductibleBase, so a partial edit cannot silently certify figures nobody re-checked.src/lib/expense-attribution.tsis the one resolver; every listed call site composes it. Identical output for existing data by construction, since the column is nullable and backfilled.node --import=tsx scripts/backfill-expense-attribution.ts, dry-run by default, prints before/after dollar coverage per job and writes the remainder to a CSV for Marge with a reason per row./reports/tax-paid-at-source, the WA excise deduction, gated byfinancialReportson both the page and the CSV route.Mobile is a separate repo and is untouched; the server contracts it needs (including
overheadProjectIdon/api/mobile/me) are built and recorded as an as-built table in spec §5.Review summary
Two Codex rounds. The findings that changed the design, not just the code:
estimateIdon re-attributed rows. It kept a bookkeeper'sprojectIdbut still wrote the match's estimate back, leaving the expense on job B for every reader and on job A's estimate for cascade-delete and billing.projectIdandestimateIdare the same fact said twice, so they are written together by oneupdateManywhose predicate isprojectId: null, and never again. The carve-out bought a row following its job to a newer estimate and paid for it by making the rule conditional — which is exactly how the original bug got in. Re-pointing an estimate belongs to an explicit re-attribution path, not to an import. Recorded in spec §3.1.groupBy(["estimateId"])in place and called it "identical output". It was not: the ranking resolved a job through the estimate while the monthly series used the resolver, so re-attributed dollars ranked under one job and plotted under another. Now both use the resolver.Expense.itemIdisON DELETE SET NULLand was never scoped to the expense's own estimate, so a stored link can point at another job's line item. The item's project must now equal the expense's resolved project; anything else is skipped with reasonitem-outside-estimateand surfaced for a human.{ costCodeSource: { notIn: [...] } }alone would have written nothing. SQLNULL NOT IN (…)is NULL, so every legacy row (source NULL) was excluded — presenting as "the rules matched nothing", which is plausible enough to go unnoticed.notHumanCodedExpenseWhere()carries an explicit NULL branch.src/lib/csv-safe.tsneutralizes formula-leading text (including behind invisible whitespace). Numbers are exempt and use a fixed-point formatter, so-12.50stays a number and nothing emits exponent notation. The backfill's remainder CSV uses it too — its own escaper only quoted, and the vendor names in that file are OCR output. Flagged, not fixed:src/lib/sales-tax-report.tshas the same pre-existing gap in its ownescapeCsv.projectId; pass (c)'s predicate still asserted the pre-fill value, so every legacy row pass (a) touched then matched nothing — an--applythat reported success and coded nothing. The test stub returned{count: 1}without mutating state, which is precisely why it went unnoticed. The stub is now stateful and honours predicates (including SQL'sNULL NOT IN (…)).projectIdand ignored it, offering every company code to the model), QBO suggestions, the manual expense edit and the backfill now all require the code to be a phase of that job.installedAtCustomerwas defaulting to true for any non-overhead project, which turned "nobody looked at this" into a deduction on a state return — and a job receipt is just as likely to be consumables, tools, fuel or a service. WAC 458-20-102(12)(b) allows the cost of the articles actually resold. Silence is now NULL everywhere; the report counts only an explicit true. NewExpense.taxDeductibleBaselets a bookkeeper allocate the resold portion of a mixed receipt, and the correction path isPATCH /api/expenses/[id], not thePUTon that route (PUTis gated byassertExpenseMutableOutsideQbo, which excludes every pipeline-booked row, i.e. exactly the rows the tax report is made of, and now rejects the tax fields by name).PATCHeditsinstalledAtCustomer,taxDeductibleBase,taxAmount,taxAtSourceandcostCodeIdonly, validated0 ≤ base ≤ amount − taxand0 ≤ tax ≤ 12%against the row the request leaves behind.taxAtSourceis unchanged — it stays the factual "tax was charged on this receipt".Tests
npm run test:unit— 847 tests, 845 pass, 0 fail (2 pre-existing PG-dependent self-skips).npm run build— 0 errors.npx tsc --noEmitclean.New:
expense-attribution,expense-cost-suggest,apply-expense-attribution,backfill-expense-attribution,tax-at-source-report,tax-at-source-query,company-financials-spend-attribution,expense-phase-scope. Theqbo-expense-syncsuite is now wired intotest:unitso the capture/manual no-overwrite guards actually run in CI.Two guards were mutation-checked: reverting the charts ranking loop to
e.estimate?.projectIdfails 2 of its 3 cases, and reverting the backfill's post-fill predicate fails 2 of the backfill's.Deploy order
Both schema scripts run against prod before merge, in this order. Migrations are not applied automatically here, so a merge ahead of them means P2021/P2022 on every page that touches the new columns (CLAUDE.md pre-deploy rule 2).
node scripts/apply-receipt-intake.mjs --yes --expect-db <db> --expect-host <host>— createsReceiptIntake. This one first:apply-expense-attributionadds two columns to that table behind ato_regclassguard and skips them silently if the table is absent. Let its verification pass complete (columns, constraints, and the partial unique index checked by definition).node scripts/apply-expense-attribution.mjs --yes --expect-db <db> --expect-host <host>— theExpensecolumns, the FK, the index, and theprojectIdbackfill. Let its verification pass complete too; it asserts the FK bypg_get_constraintdef, not by name, and asserts zero expenses left unattributed against a known estimate project.Both are additive and idempotent — a second run reports every statement ok and 0 rows updated.
main).node --import=tsx scripts/backfill-expense-attribution.ts(dry run) → Justin reads the coverage table and the remainder CSV →--apply. A re-run must then report 0 planned writes.None of these scripts has been run anywhere yet.
🤖 Generated with Claude Code