feat(receipts): ReceiptIntake state machine + intake endpoint + worker (pipeline v2 phase 1) - #440
feat(receipts): ReceiptIntake state machine + intake endpoint + worker (pipeline v2 phase 1)#440Clarion1631 wants to merge 67 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
VERDICT: REQUEST_CHANGES |
0b423f4 to
9111edf
Compare
|
Request changes. Several paths can lose receipts or report incomplete bookings as successful.
VERDICT: REQUEST_CHANGES |
|
Found eight release-blocking issues:
VERDICT: REQUEST_CHANGES |
|
Required changes:
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>
|
Required changes:
VERDICT: REQUEST_CHANGES |
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>
|
Required changes:
VERDICT: REQUEST_CHANGES |
…CES not rows (A) The boolean `secureObjectExists` collapsed "confirmed 404" and "storage is unhappy" into false, and the replay path re-uploads and re-points on a false — so a transient fault orphaned the object that was really there and left the row pointing at a second copy. The route already reads the tagged `receiptObjectSize` (c2e6408) and answers 503 on transient; this deletes the collapsing helper so nothing can reach for it again, and adds the classification tests (empty listing and 404 are missing; 5xx/401/429/throw/sizeless are transient) plus a guard that the fault branch precedes the healing one. (B) finalize took `via === "secret"` as blanket authority over any id, so the Apps Script key could publish, re-point and attach a job to a mobile capture or web upload that belongs to a person. It now selects the row's `source` and requires it in `auth.allowedSources` — the same list that scopes creation — answering 403 source-not-owned before any detail is returned or written. Unit guard plus an e2e that seeds a mobile row and proves nothing is disclosed or changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…s, atomic terminal release Round-14 items 2-5 plus the three interim ones. Item 1 (uploadLeaseVersion) is NOT in this commit — it is next. (2) The sourceRef validator now matches what the Apps Script actually sends: `drive:<fileId>`, `email:<gmailMsgId>:<sha16>`, `chat:<messageResourceName>:<idx>`. One implementation in decideSource, so both endpoints get it; e2e drives the production formats and the `drive:` / oversized rejections through both doors. (3) Expense.receiptUrl holds `receipt-intake://<bucket>/<path>` — a signed URL written into that column is dead ten minutes later, and a bare path does not say which bucket. resolveReceiptUrl() mints a short-lived URL and follows an object that moved (sealed, archived) via the intake row. Wired into resolveDocUrl (so every existing reader gets it), the expenses tab loader, and ai-review, whose SSRF check now names the `/sign/` prefix and runs on the RESOLVED url. (4) One ceiling everywhere: QBO_ATTACHMENT_MAX_BYTES = 8 MiB, used by the bucket policy, /start's declared-size check, inspectStoredObject and the booking preflight. 15 MiB at the door and 8 MiB at the books meant everything in between was stored, read, and then stranded after we had told the sender we had it. (5) Early terminal outcomes (multi-doc, non-receipt, zero/refund, no-job) now go through applyState, which releases claimToken/claimedAt/nextRetryAt in the same fenced write. applyRead is the ONE lease-keeping write and its type pins it to "RECEIVED", so a terminal state cannot be routed back through it. (A) Every post-send step (the post-create phase check, the Expense commit) is inside the protected block, and parkTerminal re-reads the PERSISTED send flag instead of the claim-time snapshot — an unreadable flag RETAINS the key, because retaining costs a review and releasing wrongly costs a second Purchase. (B) migration.sql converges on the state CHECK exactly like the apply script (drop-if-different + add). The token-presence test is replaced by a semantic parity one comparing state order, convergence, scoping and the wanted_def literal. (C) `detail.fileId` is a DRIVE id or absent — it is dual-written into the driveFileId column the cutover queries. Non-Drive rows carry `intakeId`, which is now a first-class identity in journey grouping and keying (two v2 receipts sharing a DocNumber prefix no longer merge into one journey). Mutation-tested: the claim-snapshot park, the non-converging migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e rejected Round-14 item 1. `ReceiptIntake.uploadLeaseVersion` (schema + migration + apply script + verifier) is bumped every time a signed URL is issued, and it is IN the path that URL points at: `receipts/intake/<id>.v<n>.<ext>`. - /start claims the lease in ONE checked update — version, expiry and path move together — BEFORE it signs anything, on both the resume and the re-arm path. A 0-row update is a 409 publish-conflict rather than a URL for a row somebody else has moved on, and the previous lease's object is queued for cleanup. - Every destructive or publishing write fences on the version it observed: both sweeper parks, the sweeper's publish commit, the reject transaction, and publishFence (so /finalize and the single-shot heal carry it too). - The reject transaction now RE-READS the row inside itself and lets the caller judge it; the sweeper passes a verifier that refuses while the upload lease is live. The version catches a resumed lease; the re-read catches a refreshed expiry on the same one. Tests: the real interleaving (sweep decides on v1, client resumes to v2 → the fence loses, nothing deleted, nothing queued), a lease that comes back to life inside the transaction, an unchanged-lease control, and a guard that /start moves the row before it signs on both paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ylisting it
Next's action IDs are GLOBAL: the path a `next-action` POST is sent to only
decides whose middleware runs first, not which action runs. The guard was a
DENYLIST (legal pages + machine endpoints), which is the wrong shape for a
global namespace — every other public-bypass path was a live anonymous
dispatcher: /api/auth, /api/mobile, /api/pdf/*, /api/portal, /api/payments,
/api/selections/*, /login, /share/*, and the asset patterns.
Now: an action dispatch with no session cookie is 403 BEFORE the public bypass
unless the path is allowlisted. The allowlist is the output of an audit of the
anonymous route trees for server actions invoked from their client components:
/portal/** approveEstimate, approveContract, approveChangeOrder,
mark*Viewed, createDecision, submitSelectionProposal,
portalCreateMoodBoard, setPortalStageOverride, ...
/sub-portal/** subPortalUploadCOI and the sub sign-in flow.
Audited and deliberately NOT allowlisted: /login (next-auth signIn is a plain
POST to /api/auth/*, not an action), /share/** (server component reading Prisma;
its one client child imports no actions), the legal pages, and every /api route
(Next dispatches an action to the page URL the client is on, never to a route
handler).
Tests drive real requests through the proxy — 20 refused paths, 8 allowlisted
ones, prefix-not-substring cases, and the same paths without the header — because
the bug was an ORDERING one that a helper-level assertion cannot see.
Mutation-tested: removing the guard and widening it to a substring both fail.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Required changes:
VERDICT: REQUEST_CHANGES |
…bject; inline orphans sweep (1) The gate was right and my 8418a67 report was wrong: the inline endpoint carried a hand-written TWIN of decideSource — plus its own MACHINE_SOURCES, USER_SOURCES and UUID_PATTERN — so the shared validator only ever ran on /start. The copy had drifted twice over: it checked the global source set instead of the sources THIS key owns (`auth.allowedSources`), and it validated only the namespace prefix, so `drive:` with an empty tail was a permanent unique idempotency key that every later empty-tail forward collided with. The route now calls decideSource() itself and the copies are deleted; a source tripwire pins the import and the absence of each copy, and the e2e drives `drive:`, `drive:short` and an oversized ref through the inline endpoint. (2) A replay that answers "we already have it" now proves it first, on both paths: bounded metadata (one list call), present → 200, absent → 409 file-missing with retryable:true, transient → 503. The forwarders delete their only copy on a 2xx, so a row whose object had vanished was making receipts cease to exist. The lost-publish branch checks where the row points NOW, since the winner sealed the object to a new path. (3) A row with no `uploadUrlExpiresAt` never had a signed URL — the inline path writes through the server — so it now gets the 15-minute stale-STAGING grace instead of the two-hour signed-URL TTL that made every inline orphan invisible. The sweep query excludes live leases in SQL and orders null-lease rows first, then oldest, so clients still uploading cannot occupy all ten slots while the orphans behind them are never reached. Mutation-tested: the 2h fallback restored fails the new lease test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Required changes:
VERDICT: REQUEST_CHANGES |
…naming, tax warning preservation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Required changes:
VERDICT: REQUEST_CHANGES |
…ha256 bricking recovery
The e2e storage mock had no `list()`. That was not an omission, it was a wrong
answer: `receiptObjectSize` and `secureObjectSize` both establish "is this
object there, and how big is it" from list metadata, so `from.list` being
undefined made the call THROW — and both callers classify a throw as TRANSIENT,
i.e. "storage hiccuped", not "the object is gone". Every intake replay that
reached the existence check answered 503 instead of the 200-or-heal it had
earned. 12 of the 16 red receipt-intake e2e cases were this one seam.
Also fixed, both surfaced by the newly-working existence check:
* /start's recovery guard protected `fileSha256 || expectedSha256`. Only
`fileSha256` is ever verified against real bytes; `expectedSha256` is a
promise a client made about bytes it was about to upload, and on a
recoverable park that promise is exactly what was never kept. Both
recoverable parks are reached from STAGING where `fileSha256` is "", so the
unkept announcement became the identity to protect and a forwarder coming
back with a corrected hash got 409 forever on a sourceRef that had never
held a document. Narrowed to the verified hash; nothing can be overwritten,
because a rearm writes to a NEW lease path and stays parked until /finalize
verifies. (Regression from 390a62e, masked by the red suite.)
* The "park a re-upload CANNOT fix" case asserted `alreadyReceived` for a row
whose object was never uploaded — /start cannot carry bytes and the spec
cannot PUT to a signed URL. Seeded via the single-shot route, the same trick
the two neighbouring cases already use.
Endpoint-level sourceRef parity (Phase 3 finding): the inline endpoint already
routes through decideSource() as of afda5be, but nothing proved the two doors
AGREE. Added a case-table driving right-namespace/wrong-shape, control
characters, whitespace, oversize and namespace-mismatch refs through both, and
asserting the same status AND the same reason from each — a forwarder that can
tell the doors apart will learn to prefer the lenient one, which is how they
drifted in the first place.
tests/supabase-storage-mock.test.ts pins the method surface and the
missing-vs-transient classification, so the next omitted method fails loudly
instead of looking like flaky infrastructure. Mutation-tested: all 8 fail with
`list` removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I could not review the diff: every local read failed before execution with
VERDICT: REQUEST_CHANGES |
|
Required changes:
VERDICT: REQUEST_CHANGES |
…line, receipt links, storage orphans, stuck-upload detection Addresses 5 of 6 REQUEST_CHANGES findings from the Codex adversarial review of PR #440 (a defense for the 6th, the money-pipeline e2e requirement, is posted as a PR comment): 1. bookReceipt() now writes a freshly-booked Expense as status "Reviewed", not "Pending" — it already carries a qbPurchaseId, so it is QBO-managed from birth like every other linked Expense, and approve/edit/delete already reject anything with a qbPurchaseId (qbo-expense-guard.ts). The old "Pending" status put it in the bookkeeper's actionable review queue with no route able to act on it, and a later QBO sync flipping it to "Reviewed" looked like human review that never happened. 2. The intake worker's per-row Gemini read now shares the invocation's ONE deadline instead of getting a fresh 25s budget regardless of how much of the 60s maxDuration is left (worker.ts's new readBudgetFor, wired into the cron route via remainingBudgetMs). A row reached late in a batch gets whatever runway is actually left, or is skipped (AI_UNAVAILABLE, no `attempts` spent) if there is not enough of it to be worth starting. 3. manager/receipts/page.tsx now resolves `receipt-intake://` references to short-lived signed URLs (the new resolveReceiptUrls batch helper in receipt-url.ts) before handing expenses to the client — newly booked receipts can now be opened from the bookkeeper review screen. 4. sealAndPublish() no longer leaks the canonical object it sealed when its commit CAS loses. It now checks where the row's storagePath actually points: if some OTHER publisher's content is there, the copy this call made is a genuine orphan and is cleaned up (reusing the existing deleteObjectOrRecord retry-queue mechanism); if the winner is pointing at this exact same content-addressed path (a double-publish race on identical bytes), nothing is deleted. 5. Pipeline health's STAGING stuck-count now also checks uploadUrlExpiresAt (falling back to the sweeper's own STAGING_SWEEP_MINUTES grace window for rows with no signed URL), not createdAt alone — a slow upload still inside its own two-hour lease no longer reads as a stuck receipt. All touched suites green: test:receipt-intake (363/363), test:pipeline-health (55/55), test:unit (1210/1210), and `npm run build` (typecheck + next build). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed fixes for findings 1-5 (862c7d7). Defending finding 6 rather than fixing it: 6. That suite already runs in CI on every PR against this repo independent of If there's a specific interaction with the money pipeline you're concerned |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…euse, archive secret in deploy docs Three REQUEST_CHANGES findings from the PR #440 adversarial review: 1. An inline upload failure deleted its STAGING row by id alone. A concurrent replay of the same sourceRef can find that row via respondToSourceRefConflict, upload to its own path, and publish it while the original request's upload is still failing — the unconditional delete then destroyed the now-RECEIVED row. The delete is now a fenced deleteMany on {id, state: STAGING, storagePath}, a no-op once another request has moved the row on. publishStagedRow's own CAS gained the same storagePath check, so a publish can never land against an object the row no longer points at. 2. Every retrying /start call for the same sourceRef bumped uploadLeaseVersion and repointed storagePath unconditionally, even when the existing lease had not expired — invalidating the original caller's in-flight upload and deleting the object it was about to PUT to. An unexpired lease is now served a freshly signed URL for its EXISTING path (createSignedUploadUrl does not revoke a prior token for the same path), with no repath and no delete. Only an expired or non-STAGING row still rearms. 3. The deploy checklist (spec §"CUTOVER SEQUENCE" and the PR's own "Deploy order") named only RECEIPT_INTAKE_SECRET. authenticateIntake requires RECEIPT_ARCHIVE_SECRET too, and fails every archive-mirror request closed if it's unset or equal to the ingest secret. Both docs now call out both variables as mandatory and distinct. Two source-text tests updated to match: the publishStagedRow CAS regex now expects the storagePath clause, and the /start lease-stamp test now expects a fourth signUpload call site that deliberately skips the lease stamp because it is reusing one still live. npm run build: 0 errors. npm run test:unit: 1210/1210 pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
Phase 1 of the receipt pipeline v2 rebuild. Spec:
docs/plans/PHASE-1-INTAKE-CORE-SPEC.md.What and why
Today every receipt goes through a Google Apps Script that reads it with Gemini, dedups it against Script Properties, emails it to QuickBooks, and renames it into a Drive archive. That script is the single point of failure for job costing: when it stalls, expenses stop reaching ProBuild and nobody finds out until a variance report looks wrong.
This PR moves the intake, the read, the dedup and the booking into ProBuild, behind one durable row.
ReceiptIntake— one row per inbound document, with an explicit state machine (STAGING RECEIVED READ NEEDS_JOB NEEDS_REVIEW BOOKING BOOKED ARCHIVED DUPLICATE VOID NON_RECEIPT).stateis a String with a SQL CHECK, matchingBankLine.stateandExpense.status.POST/GET /api/receipts/intake— the one front door for the mobile app (Bearer), staff (session), and the Apps Script forwarders (x-receipt-intake-secret). Idempotent onsourceRef, and cheap: hash, store, insert, return. No AI call on the request path./api/cron/receipt-intake-worker(every 5 min, ≤10 rows) — reads with Gemini, dedups, routes, and books viacreateQBReceiptPurchase.RECEIPT_INTAKE_DRYRUNunset, rows are read, deduped and routed, and nothing is booked: zero QuickBooks calls, zeroExpenserows. That is asserted by counting injected fakes, not by reading the code.Deliberate reuse, not reimplementation:
book.tsimportscreateQBReceiptPurchasedirectly (one QBO write core, never reached over HTTP), andkeys.tsis a verbatim port of the v3.6 Apps Script dedup rules so v1 and v2 agree on every archived file during the shadow week.Design notes worth reviewing
Expense.amountis the GROSS total, tax included (Justin's call, 2026-09-01 — this overrides the plan's §4.5 pre-tax wording). The QBO Purchase still splits sales tax onto its own reclaimable account. ButExpensehas no tax column and the QBO-imported expenses already record the gross line total, so booking pre-tax here would put two meanings ofamountin one table.ReceiptIntake.taxCentskeeps the split for Phase 3'sExpense.taxAmount.prisma/prisma-blind-spots.json./api/receipts/intakebypasses the proxy (exact match, so a machine caller gets a clean 401 rather than a 307 to/login), which makes the handler the only gate. Please review the auth block specifically.Review summary
Two Codex rounds. Round 1 raised 6 blockers and 12 issues; round 2 raised 2 blockers and 3 issues. All addressed, each with a test:
STAGINGand published in one UPDATE after the upload lands; staleSTAGINGrows are swept after 15 minutes.pg_advisory_xact_lock(hashtextextended(weakKey))inside the READ→BOOKING transition serializes exactly those two, without wrapping Gemini and QuickBooks calls in one long-lived pooler transaction.sourceRefreuse with different bytes returned 200 and swallowed a second, real receipt. It is now decided onfileSha256: same bytes is a replay, different bytes is 409 and storage is never touched.sourceandsourceRefare minted server-side, and only shared-secret forwarders may declare drive/email/chat.Tests
Function injection throughout, no
mock.module(CI pins Node 20, where it corrupts the require chain).tests/receipt-intake-keys.test.tsuses eight real August 2026 archive filenames as fixtures — v1 built those names from the same cleaned fields, so a changed key there is a shadow-week mismatch rather than a refactor.e2e/receipt-intake.spec.tscovers the 401 matrix (no credentials, bogus session cookie, wrong secret, empty secret), the idempotent double POST, provenance rejection, the SHA-conflict 409 and its namespace scoping, the GET role gate proven with the EMPLOYEE storage state, and the archive callback. Every negative case asserts the absence of aLocationheader, because a redirect is what a forwarder mis-reads as "retry later" forever.Deploy order
node scripts/apply-receipt-intake.mjs --yes --expect-db <db> --expect-host <host>against production BEFORE merging. Auto-deploy is on, so merging ships this; the new Prisma client selects these columns immediately and any page touching them throws P2022 until the table exists. The script is additive and idempotent — a second run reports every statement "ok" and changes nothing.node scripts/snapshot-prisma-blind-spots.mjs --writeagainst production and commit the result. The new partial index and CHECK constraint were added toprisma/prisma-blind-spots.jsonby hand (the snapshotter needs a live production connection this branch never had), so their rendered definitions are asserted rather than observed. CI'smigrationsjob is what will catch a mismatch.RECEIPT_INTAKE_SECRETandRECEIPT_ARCHIVE_SECRETin Vercel — both required, new, and deliberately independent of each other and ofRECEIPT_INGEST_SECRET.authenticateIntakefails every request closed (401) if either is unset, and refuses both (also 401) if they are set to the same value, since that would silently re-merge two capabilities that are meant to stay apart (ingest vs. archive-read). Give each forwarder only its own value as a Script Property:RECEIPT_INTAKE_SECRETto the drive/email/chat ingest forwarders,RECEIPT_ARCHIVE_SECRETto the nightly Drive archive mirror. MissingRECEIPT_ARCHIVE_SECRETspecifically would not fail loudly at deploy time — it only surfaces when the archive mirror's next poll gets a blanket 401.RECEIPT_INTAKE_DRYRUNunset — that is dry-run mode, and it is where this should sit for the shadow week. Cutover is Justin's explicit call: set it to the literalfalse.The Apps Script forwarder changes are a separate PR in
qbo-clasp; the endpoint contract it codes against is in spec §7, including the six places the build differs from the original plan.🤖 Generated with Claude Code