diff --git a/.env.example b/.env.example index 44ef57706..92ab08bee 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,44 @@ MCP_SECRET= BANK_LEDGER_INGEST_SECRET=generate_a_strong_random_secret_here BANK_LEDGER_STATUS_SECRET=generate_a_different_strong_random_secret_here +# --- Receipt Pipeline v2 (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md) --- +# TWO machine secrets, deliberately separate: they belong to different programs +# with different blast radii, and they rotate independently. Setting them to the +# SAME value is refused at runtime — that would silently re-merge the two. +# +# The Apps Script forwarders. May only INGEST (POST /api/receipts/intake, +# /start, /{id}/finalize) and only under source drive|email|chat. Cannot read +# the queue and cannot archive. +RECEIPT_INTAKE_SECRET= +# The nightly Drive archive mirror. May only READ BOOKED/ARCHIVED rows +# (GET /api/receipts/intake?state=BOOKED) and report back what it archived +# (POST /api/receipts/intake/{id}/archived). Cannot create or publish a row. +RECEIPT_ARCHIVE_SECRET= +# +# Storage note (not an env var): receipts live in their OWN private bucket, +# `receipt-intake`, carrying an 8 MiB file-size limit and an allow-list of the +# six formats QuickBooks can attach. Two-step uploads go straight to a signed +# URL and never pass through this server, so the bucket is the only place a +# too-large or wrong-type write can actually be refused; MAX_STORED_BYTES in +# src/lib/receipt-intake/intake-core.ts only lets the server reject the object +# after the fact. It is separate from `secure-docs` because those limits are +# per-bucket, and because a signed upload URL is a write capability that must +# not point at the bucket holding signed contracts. +# +# Do NOT create it by hand: scripts/apply-receipt-intake.mjs creates it when +# missing and VERIFIES it when present (it needs SUPABASE_URL and +# SUPABASE_SERVICE_KEY in the environment, and exits nonzero if the bucket +# exists with a different limit, MIME list, or public flag). +# +# Shadow mode. UNSET or "true" = dry run: rows are read, deduped and routed, and +# NOTHING is booked. Set to the literal "false" only at cutover. +RECEIPT_INTAKE_DRYRUN= +# The instant the Apps Script stopped booking (ISO 8601). Written at the flip to +# forwarder mode; the first live worker pass uses it to split the shadow backlog +# into "v1 already booked this" and "nobody booked this". With it unset the +# cutover refuses to touch either side. Can also live in the `cutoverV1StoppedAt` +# AutomationSetting row, which takes precedence. +CUTOVER_V1_STOPPED_AT= # Payroll period configuration (src/lib/payroll-config.ts). ALL OPTIONAL — the # built-in defaults are shown, and they are DEFAULTS PENDING JUSTIN'S DECISION # (Phase 5 spec section 7, risks 2 and 3), not settled policy. Not secrets. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d3aa0a60..06d7f2cd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,32 @@ jobs: # The focused integration regression deliberately drifts the disposable # database's function and trigger after proving the checker recognizes # the production snapshot. It therefore runs last in this job. + # The receipt-intake claim transaction against a REAL Postgres. The rest + # of that feature's suites mock every DB call, so the SQL — a void + # function read through $queryRaw, a claim that behaves differently than + # its mock — is the one part they cannot see. + - name: Receipt-intake claim + advisory locks against real Postgres + run: npx tsx --test tests/receipt-intake-claim-db.test.ts + env: + RECEIPT_INTAKE_DB_TEST_URL: postgresql://probuild:probuild@localhost:5432/probuild_migrations + + # THE APPLY SCRIPT ITSELF, run end to end the way production will run it. + # Its exported helpers are unit-tested and its statement list is replayed + # by the DB-gated suite, but main() is executed by nothing else: not the + # flag parsing, not the identity gate, not the statements running in order + # against a real server, not the second run being a genuine no-op. It + # builds two databases -- one pre-Phase-1, and one carrying the OLD + # Phase-1 table with DEFAULT 'RECEIVED' so the additive upgrade and the + # state-default repair are exercised -- and asserts both end up matching + # what the committed migration produces. + # + # `--target ci` inside the driver: the ambient URL, no production baseline + # or project-ref check, and a refusal if that URL looks like Supabase. + - name: Apply script end-to-end against real Postgres + run: node scripts/ci-apply-receipt-intake-e2e.mjs + env: + APPLY_E2E_SERVER_URL: postgresql://probuild:probuild@localhost:5432/postgres + APPLY_E2E_DB: probuild_apply_fresh # Two REAL connections contending on a User row. Every other concurrency # proof in the suite is an injected sequence, which shows the branch exists # but not that PostgreSQL serializes the way the code assumes. Runs BEFORE @@ -500,6 +526,18 @@ jobs: # against the throwaway DB — so unlike the provider secrets above it's a # literal here rather than a repo secret (there's nothing to rotate). DEPOSIT_INGEST_SECRET: "e2e-ci-deposit-ingest-secret" + + # Shared secret for the Receipt Pipeline v2 intake endpoint + # (src/app/api/receipts/intake/route.ts). Same reasoning as + # DEPOSIT_INGEST_SECRET above: nothing external depends on this value, it + # only gates e2e/receipt-intake.spec.ts against the throwaway DB, so it is + # a literal rather than a repo secret. The spec's "env var unset" case is + # a unit test (tests/receipt-intake-auth.test.ts), not this job. + RECEIPT_INTAKE_SECRET: "e2e-ci-receipt-intake-secret" + # The archive mirror's key is DELIBERATELY different — the specs assert + # that cross-use between the two capabilities is a 403. + RECEIPT_ARCHIVE_SECRET: "e2e-ci-receipt-archive-secret" + # The deposit sweep's live-apply switch (src/lib/deposit-sweep.ts). It is # OFF by default — booking money on an amount-only match is Justin's # decision, not a default — so the e2e cases that exercise the MONEY @@ -507,6 +545,7 @@ jobs: # would be. Everything here runs against the throwaway CI database with # QuickBooks mocked (E2E_QBO_MOCK), so no real money can move. DEPOSIT_SWEEP_LIVE_APPLY: "true" + # Forces the Stage A daily-log task matcher (daily-log-task-match.ts) onto # its deterministic keyword fallback instead of calling Gemini, so # time-suggestion.spec.ts's Stage A end-to-end test is reproducible in CI. diff --git a/docs/plans/PHASE-1-INTAKE-CORE-SPEC.md b/docs/plans/PHASE-1-INTAKE-CORE-SPEC.md index f69cf3640..c72ee84ab 100644 --- a/docs/plans/PHASE-1-INTAKE-CORE-SPEC.md +++ b/docs/plans/PHASE-1-INTAKE-CORE-SPEC.md @@ -99,7 +99,18 @@ model ReceiptIntake { createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) // file (Supabase secure-docs, private) - storagePath String // receipts/intake/. in SECURE_BUCKET + storagePath String // receipts/intake/. in the `receipt-intake` bucket + // When the signed upload URL /intake/start last issued stops working. The + // sweeper uses THIS, not createdAt: a row whose URL was re-issued is older + // than its lease, and judging it on row age parked receipts whose own upload + // link was still live. Null on rows that never had a signed URL. + uploadUrlExpiresAt DateTime? + // Bumped every time a signed upload URL is issued, and EMBEDDED IN THE PATH + // that URL points at (`receipts/intake/.v.`). /start claims the + // new lease in ONE checked update before it signs anything; every park, + // publish and reject fences on the version it observed. That is what makes a + // sweep verdict about v1 land on nothing once the client has resumed on v2. + uploadLeaseVersion Int @default(0) fileName String? mimeType String fileSize Int @@ -158,6 +169,8 @@ CREATE TABLE IF NOT EXISTS "ReceiptIntake" ( "suggestedConfidence" DOUBLE PRECISION, "createdById" TEXT, "storagePath" TEXT NOT NULL, "fileName" TEXT, "mimeType" TEXT NOT NULL, "fileSize" INTEGER NOT NULL, "fileSha256" TEXT NOT NULL, + "expectedSha256" TEXT, "uploadUrlExpiresAt" TIMESTAMP(3), + "uploadLeaseVersion" INTEGER NOT NULL DEFAULT 0, "vendor" TEXT, "txnDate" DATE, "totalCents" INTEGER, "taxCents" INTEGER, "docType" TEXT, "refNumber" TEXT, "memo" TEXT, "readJson" TEXT, "readAt" TIMESTAMP(3), "dedupStrongKey" TEXT, "dedupWeakKey" TEXT, "duplicateOfId" TEXT, @@ -184,7 +197,24 @@ CREATE INDEX IF NOT EXISTS "ReceiptIntake_createdAt_idx" ON "ReceiptIntake"("cre -- expenseId -> "Expense"(id) ON DELETE SET NULL ``` -Run the apply script against prod BEFORE merging (CLAUDE.md pre-deploy rule #2). +Run the apply script against prod BEFORE merging (CLAUDE.md pre-deploy rule #2): + +```bash +SUPABASE_URL=... SUPABASE_SERVICE_KEY=... node scripts/apply-receipt-intake.mjs --target prod --yes --expect-db postgres --expect-host +``` + +It does BOTH halves of the rollout and verifies each: + +1. **Schema** — additive, idempotent DDL, then a shape check (every column, the CHECK + constraint, the FKs, and the partial unique index verified by its DEFINITION, not its + name). +2. **Storage** — creates the private `receipt-intake` bucket, or verifies the existing one. + It exits nonzero on a different file-size limit, a different MIME allow-list, or a public + bucket, and never rewrites one. + +Both halves are safe to re-run. The bucket step needs `SUPABASE_URL` and +`SUPABASE_SERVICE_KEY`; without them the script refuses rather than skipping it, because a +missing bucket policy is invisible until a 400 MB object is already stored. ## 3. Endpoint contracts @@ -201,7 +231,7 @@ Run the apply script against prod BEFORE merging (CLAUDE.md pre-deploy rule #2). `threadName?`) or JSON `{fileBase64, mimeType, fileName?, source, sourceRef?, projectId?, costCodeId?, threadName?}`. `source` in mobile|email|drive|chat|web. Machine callers MUST send `sourceRef`; session/Bearer callers get `web:` / `mobile:` minted - server-side. Max 15 MB. Accept pdf/jpeg/png/heic/webp/gif/txt; sniff magic bytes for + server-side. Max 8 MiB (QuickBooks' attachment ceiling). Accept pdf/jpeg/png/heic/webp/gif; sniff magic bytes for images the way `receipts/parse` does (route.ts:37). - **Behavior**: sha256 the bytes; create the row (catch P2002 on `sourceRef` and return the existing row with `{ok:true, alreadyReceived:true}`); upload to `SECURE_BUCKET` at @@ -282,8 +312,15 @@ Run the apply script against prod BEFORE merging (CLAUDE.md pre-deploy rule #2). of the same file), else the intake `id`; `fileBase64` via `downloadDocBytes(storagePath)`; `projectName` = project.name. 5. On `ok:true`, one transaction: create `Expense` (estimateId; costCodeId = chosen, else - `matchCostCode(suggestedPhaseCode)`; amount = pre-tax amount when tax was split, else - total — mirrors the QBO COGS line; vendor; date=txnDate; status "Pending"; receiptUrl + `matchCostCode(suggestedPhaseCode)`; **amount = the GROSS total paid, tax INCLUDED** + (Justin, 2026-09-01 — this REPLACES the "pre-tax" rule this line used to state; see + the as-built note in §7); vendor; date=txnDate; **status "Reviewed"** (as-built — not + "Pending": the row is created WITH `qbPurchaseId` already set, so it is QBO-managed + from birth exactly like a QBO import, and `assertExpenseMutableOutsideQbo` + (`qbo-expense-guard.ts`) rejects approve/edit/delete on anything carrying a + `qbPurchaseId`. A booked-then-"Pending" row would sit in the bookkeeper queue's + actionable list (`manager/receipts/page.tsx` lists `status: "Pending"`) with no route + able to act on it — "Pending" is not a reachable state for a booked row); receiptUrl = Drive view URL when a Drive fileId is known, else the `secure:` ref; qbPurchaseId) and set the row BOOKED {qbPurchaseId, expenseId, bookedAt}. Also log one `AutomationEvent {kind:"receipt-push", source:"intake-worker"}` so the /automation @@ -341,6 +378,379 @@ All gated on Script Property `V2_FORWARD === "true"`; all send `x-receipt-intake original for v1 to process as usual. The move-to-`_Forwarded` branch (which hides the file from v1) activates only under a second property `V2_LIVE === "true"` at cutover. +### As-built notes (2026-09-01) — where the implementation differs from the plan above + +The Apps Script side is a separate PR in `qbo-clasp`. The endpoint contract it must code +against is the one above, with these six clarifications from the build: + +1. **`GET /api/receipts/intake` accepts the shared secret as well as a staff session.** + §3 said staff-only, but §6's nightly mirror polls `?state=BOOKED` with + `x-receipt-intake-secret` — it has no session to present. A SESSION caller still needs + ADMIN | MANAGER | FINANCE (403 otherwise); the secret caller is the mirror. +2. **`POST /api/receipts/intake//archived` is also on the proxy's public bypass**, + spelled out as its own exact pattern (`/api/receipts/intake/[^/]+/archived`). It is a + DESCENDANT of the intake path, and the intake bypass is exact-match on purpose, so + without its own entry the proxy would answer the mirror with a 307 to /login. The + route is secret-only: a session, however privileged, is refused, because only the + mirror can know that a file now exists in Drive. It is also state-conditional + (`updateMany WHERE state = 'BOOKED'`), so two mirror runs racing one row cannot both + claim the transition — the loser gets 409. +3. **`threadName` is accepted and NOT persisted.** The chat forwarder should keep sending + it, but `memo` belongs to the read step (it holds a check's handwritten memo line) and + there is no other column for it yet. Phase 2 adds one when the queue page needs to link + back to the thread. +4. **The phase suggestion is resolved to `suggestedCostCodeId` at READ time**, not at + booking, using the same `matchCostCode` the v1 ingest uses. Booking then takes + `costCodeId ?? suggestedCostCodeId`. Same outcome as §4 step 5, but the suggestion is + visible in the queue before anything books. +5. **A non-Drive row's intake id is a UUIDv4**, so its QBO DocNumber is the first 21 + characters of that UUID (risk 5 above, unchanged in substance — the PrivateNote marker + check in `createQBReceiptPurchase` still turns any truncation collision into a + `docnumber-conflict` rather than a mis-attached Purchase). +6. **Tests live in `tests/receipt-intake-*.test.ts`, run by `tsx --test`**, not + `test/receipt-intake/*.test.mjs`. That is the repo's existing convention (every other + suite is there and wired into `npm run test:unit`); the rule that mattered — no + `mock.module`, function injection only, because CI pins Node 20 — is followed. + +### Round-1 review changes (2026-09-01) + +**DECISION (Justin, overrides §4.5): `Expense.amount` is the GROSS total paid, tax +included.** The QBO Purchase still splits the sales tax onto its own reclaimable account — +that is unchanged and it is what the reseller-permit filing reads. But `Expense` has no tax +column, and the expenses already imported from QuickBooks (`lib/qbo-expense-sync.ts`) +record the gross line total, so booking pre-tax here would put two meanings of `amount` in +one table and silently under-count every receipt this pipeline touched. `ReceiptIntake.taxCents` +keeps the split; Phase 3 adds `Expense.taxAmount` and can derive the pre-tax figure without +re-reading a single document. + +Also changed, all with tests: + +- **Dry-run rows are excluded from the claim, not skipped inside it.** The batch is ten + rows; after a couple of shadow days the ten oldest were all parked ones, so no NEW receipt + was ever reached and the queue looked healthy while processing nothing. A one-shot + `requeueDryRunParked` on the first live pass un-parks the backlog (and flips `dryRun` in + the same statement, or the rows would re-park forever). This is the one thing that changes + a row's `dryRun` after intake. +- **Read budget: 25s per row, 2 retries per model at 1s/3s.** The Apps Script's 5 retries at + 2s..32s suits a 6-minute trigger, not a 60-second function shared by ten rows. The worker + also stops TAKING new rows once 40s of its 60s are gone. Exhaustion returns AI_UNAVAILABLE, + which never spends `attempts` — but `busyPasses` now counts them and parks the row after 20 + (v3.4), so an endless outage still ends in front of a human. +- **`sourceRef` reuse is decided on `fileSha256`.** The row is inserted BEFORE the upload, so + the unique index is the decision point: same bytes is a replay (200, the existing row), + different bytes is 409 `sourceRef-conflict` and storage is never touched. Previously both + cases got a 200 and a second, real receipt could be swallowed. +- **Vendor is not part of the strong KEY, but it is part of the CONFIRMATION.** The v3.6 + vendor-less key stands; a same-total hit whose canonical vendor differs now routes to + NEEDS_REVIEW `vendor-mismatch:` rather than DUPLICATE. +- **Negative and zero totals** route to NEEDS_REVIEW `refund-or-zero` (replacing + `zero-total`) and claim no key. +- **A second weak-dedup check runs INSIDE the READ→BOOKING transaction**, the last instant + before money moves. The claim advisory lock is one global constant so only one batch runs + at a time. +- **A NEEDS_REVIEW park RELEASES the strong key unless a QBO send was attempted** (v3.5 + rule): otherwise the key is held by a document that never became a purchase, and a + corrected re-send is quarantined against nothing. +- **Transient throws (storage, Prisma, network) retry on the normal backoff.** Only the + classified QBO fault types are terminal. `MAX_BOOK_ATTEMPTS` is now `>=`, so it means 20 + attempts in total. +- **Non-secret callers cannot choose `source` or `sourceRef`** — the server mints both from + the auth kind; anything else is a 400. Only shared-secret callers may declare + drive/email/chat. An existing row's fields come back only to its creator or a bookkeeping + role. +- **The shared-secret GET is limited to `state=BOOKED|ARCHIVED`** and a minimal field set + (no error text, hashes, or user ids). +- Archive callback is idempotent for an identical retry (200), 409 only for a DIFFERENT + Drive file id. HEIC sniffing accepts `hevc`/`hevx`; `mif1`/`heif` store as image/heif. + P2002 resolves the owner by `dedupStrongKey` instead of string-matching Prisma's `meta` + (which is empty for a partial index on some engine builds). The apply script matches + `--expect-host` exactly and verifies the index is UNIQUE with the exact predicate. + +**Left as-is, deliberately:** the pre-existing asset-suffix proxy bypass (not introduced +here); multipart buffering before the size check (the platform body limit applies first); +PDFs carrying embedded JavaScript (never opened server-side — the bytes go to Gemini and to +QBO as an attachment). + + +### Objects are sealed on finalize + +The upload path is writable by whoever holds the signed URL, and that URL is `upsert: true` +so a resumed `/start` can replace its own partial upload. Both are necessary, and together +they mean the bytes at the upload path can change AFTER verification. + +So `/finalize` verifies, then **copies** the bytes to `receipts//.` — a +content-addressed path the client was never given a URL for — deletes the upload path, and +points the row there. Every later reader (the Gemini read step, the booker) re-hashes what +it downloads and refuses on a mismatch: `content-changed`, terminal. A hash stored once and +never re-checked proves nothing about what is being served now. + +A failed delete of the upload path is an orphan, not a correctness problem (the row already +points at the sealed copy), so it goes on the `storage-cleanup-pending` queue. + +**Sweeper timing.** A `STAGING` row is only parked `file-missing` once the signed upload +URL's **2-hour** lifetime has passed — parking at the 15-minute sweep window declared +receipts missing while their own upload link was still usable. A late `/finalize` on a row +the sweeper already parked re-validates and **recovers** it rather than reporting +`alreadyFinalized`, which would leave a real receipt parked while telling the caller it was +fine. + +### Upload limits, and text receipts + +| path | ceiling | why | +|---|---|---| +| `POST /api/receipts/intake` (JSON) | **3 MiB raw** | base64 inflates by 4/3, so 3 MiB encodes to ~4 MiB and fits the serverless body cap. 4 MiB raw would be a ~5.4 MiB request that dies at the edge with a 413 this code never sees. | +| `POST /api/receipts/intake` (multipart) | **4 MiB** | bytes are sent as-is. | +| two-step (`/start` + signed URL + `/finalize`) | **8 MiB** | the bytes never pass through this server. The ceiling is QuickBooks' own attachment limit: anything larger is a receipt that would be stored, read, and then stranded `unsupported-attachment:size` after we had already told the sender we had it. One constant, `QBO_ATTACHMENT_MAX_BYTES` in `intake-core.ts`. | + +Both inline ceilings answer with a 413 naming the two-step path. + +**The 8 MiB ceiling is set on the Supabase bucket as well as in code.** The signed upload +URL bypasses this server entirely, so application code cannot stop the write — it can only +refuse the object afterwards, by which time the bytes are already paid for and sitting in +the bucket. Set it where the write happens: + +> `node scripts/apply-receipt-intake.mjs --target prod --yes --expect-db … --expect-host …`, with +> `SUPABASE_URL` and `SUPABASE_SERVICE_KEY` in the environment. It creates the bucket when +> missing and VERIFIES it when present, and exits **nonzero** if it exists with a different +> file-size limit, a different MIME allow-list, or as a public bucket. It never silently +> "corrects" one: overwriting a limit somebody set deliberately is how a 400 MB upload +> becomes possible again next quarter. + +Receipts live in their **own** bucket, `receipt-intake` — not `secure-docs`: + +* Those limits are **per bucket**, so `secure-docs` cannot carry a receipt policy without + imposing it on contracts, e-signatures and invoice PDFs. +* A signed upload URL is a **write capability**. Issuing one against the bucket that also + holds countersigned contracts means a path-handling bug in intake is a write into the + contract store. +* The orphan sweep **deletes** objects, unattended, from paths read out of an event log. It + must not be able to reach anything but receipts. + +All intake reads and writes go through `src/lib/receipt-intake/bucket.ts`, which is the one +place that names the bucket. + +The server-side check stays regardless, and is checked in this order: +1. **Object metadata first** (`list({ search })` → `metadata.size`) — one small request that + costs the same whatever the object weighs. Oversize is rejected here, with no body read. +2. **Then the downloaded byte length**, as a second line for anything the metadata missed. + +An **unknown** size is `transient`, not permission to proceed: a storage hiccup, a missing +client or an API without metadata all used to fall through to the download — which is the +read this check exists to avoid, taken on exactly the objects we know least about. Both +callers retry a transient answer. + +**`text/plain` is refused with a 415.** QuickBooks cannot attach a `.txt`, so accepting one +meant reading it with Gemini and then stranding it unbookable at +`unsupported-attachment` — worse than a clear refusal at the door. v1 converted these using +Apps Script's HTML→PDF `getAs`, which has no Node equivalent: a real port means a PDF +generator with wrapping, pagination and WinAnsi encoding (pdf-lib's standard fonts THROW on +characters they cannot encode), which is a new silent-corruption surface on a money document +for the rarest input in the pipeline. The 415 says to send a PDF or an image instead. + +### Every worker write is fenced on the claim token + +`ReceiptIntake.claimToken` is re-stamped on every claim, and each row carries it through the +pass. Every transition — `finishRouting`, `promoteToBooking`, `markSendAttempted`, each +book-result write, and the `BOOKED` commit — is a CAS on `{id, state, claimToken}`. + +The one that matters most is `markSendAttempted`: it is the **last fence before +QuickBooks**. A worker whose invocation was killed and whose row has since been re-claimed +finds zero rows there and aborts with `outcome: "stale"` **having sent nothing** — so a +zombie cannot post a Purchase the live worker is about to post as well. A claim lost later, +between the create and the commit, rolls the transaction back (Expense included); the +successor's retry hits QBO's DocNumber idempotency, gets the same Purchase, and books it +once under one owner. + +### Two machine secrets, not one + +They belong to different programs, so they are different keys and rotate independently. +A single shared secret gave a script that only copies files to Drive the power to inject +Purchases into the books, and gave the ingest forwarders the power to enumerate every +receipt in the system. + +| secret | may do | may NOT do | +|---|---|---| +| `RECEIPT_INTAKE_SECRET` (the Apps Script forwarders) | `POST /api/receipts/intake`, `/intake/start`, `/intake/{id}/finalize`, declaring `source` in **drive, email, chat** only | read the queue; archive anything | +| `RECEIPT_ARCHIVE_SECRET` (the nightly Drive mirror) | `GET /api/receipts/intake?state=BOOKED|ARCHIVED` (minimal field set + signed URL), `POST /api/receipts/intake/{id}/archived` | create, publish or modify a row; declare any source | + +Cross-use is **403**, not 401: the caller is authenticated, it is just holding the other +program's key, and saying so is what makes a mis-wired script obvious instead of looking +like a rotation problem. Setting both variables to the same value is refused at runtime — +that would silently undo the split. + +### Phase suggestion: confidence, and re-validation at booking + +The reader returns `suggested_phase_confidence` (0..1) alongside the phase code. It is +persisted as `ReceiptIntake.suggestedConfidence`, so the queue can sort by it, and it is +recorded on the booking's `AutomationEvent` when the suggestion is what got used. Absent or +unparseable is **null, never 0** — "the model didn't say" and "the model is sure it is a +poor match" have to stay distinguishable. + +At booking, BOTH the captured `costCodeId` and the suggestion are re-checked against the +project the row will actually book to, via the same `isCostCodeAllowedForProject` the +clock-in uses. The row may have been read while it had no project (`NEEDS_JOB`) or a +different one that a human then corrected, and a cost code from the old project is not a +phase of the new one. A mismatch clears the code and books UNCODED with a note — the +receipt and its total are still right, and a bookkeeper assigning a phase is routine, while +an expense silently attached to the wrong phase is not. + +### Upload paths (two of them) + +`POST /api/receipts/intake` carries the file in the REQUEST BODY, so it is limited by the +serverless body cap (~4.5 MB, and base64 JSON inflates a payload by a third). It rejects +anything over **4 MB** with a 413 that names the two-step path. That limit is about the +transport, not the document. + +For anything larger — most phone photos — use the two-step flow, which never puts the bytes +through this server at all: + +1. `POST /api/receipts/intake/start` with + `{sha256, mimeType, fileName?, fileSize?, source?, sourceRef?, uploadId?, projectId?}`. + + **`sha256` is REQUIRED** — 64 lowercase hex characters, the hash of the bytes you are + about to upload. Anything else is `400 {reason: "missing-sha256"}` and no row is created. + It is the only thing that gives the row an identity before any bytes exist: without it a + reused `sourceRef` carrying a DIFFERENT document is indistinguishable from an honest + retry, and `/start` would hand out an upsert-capable URL pointed at another document's + object — a swap that would only surface at `/finalize`, by which point the original bytes + are gone. + + **The success response is a UNION, discriminated by `kind`.** Switch on it; `ok: true` + alone does NOT mean there is somewhere to PUT bytes. + + ```ts + type StartResponse = + | { ok: true; kind: "upload"; id: string; uploadUrl: string; token: string; + storagePath: string; uploadLease: string; maxBytes: number; + sourceRef?: string; state?: string; resumed?: boolean; recovered?: boolean } + | { ok: true; kind: "settled"; alreadyReceived: true; id: string; state: string }; + ``` + + - `kind: "upload"` — the row is `STAGING` (invisible to the worker) or a recoverable park + that has been re-armed, and the response carries a short-lived Supabase signed upload + URL bound to a server-chosen path. **`uploadLease` is the generation that URL was issued + under** — an opaque string; treat it as a token to hand back, never parse it. + - `kind: "settled"` — this `sourceRef` is already held AND its stored bytes still hash to + what was published, so there is nothing to upload. It carries **no `uploadUrl` and no + `uploadLease`**, deliberately. Do not look for them. + + **Concurrent `/start` calls for one `sourceRef` return the SAME `uploadLease`.** A retry + that finds a still-live lease EXTENDS it rather than replacing it — same path, same lease + version, same generation — so every 200 this endpoint hands out stays finalizable. (Until + round 19 each adoption minted its own generation and only the last was stored, which made + the earlier caller's 200 carry a lease `/finalize` refused as stale.) A call that loses a + genuine race — the row was repathed or published while its URL was being signed — is + answered `409 {error: "publish-conflict", retryable: true}`, and the remedy is to call + `/start` again. +2. `PUT` the bytes straight to `uploadUrl`. +3. `POST /api/receipts/intake/{id}/finalize` with `{uploadLease, sha256?}` -> publishes + `STAGING` -> `RECEIVED`. The server re-reads the object and derives the mime, the size and + the sha FROM STORAGE; a declared `sha256` is checked against that and a mismatch is a 409. + Over 8 MiB or an unreadable format deletes the row and refuses. + +**`uploadLease` is REQUIRED on `/finalize`, and this is a breaking contract change.** A call +that omits it, or that presents a generation the row has since moved past, is refused +`409 {error: "lease-stale", retryable: false}` **before the server reads or writes a single +object** — and the caller's remedy is to call `/start` again and use the URL and lease that +come back, not to retry the same body. + +The reason it cannot be optional: `/start` rotates the generation on every issue and every +adoption, and two `/start` calls for one row hand out URLs for the **same path**. Without +the echo, `/finalize` read whichever generation was current when it happened to arrive, so a +delayed finalizer silently adopted a lease issued after it started — inspecting a second +client's half-written object and rejecting (deleting) the row while that client still held a +working URL. The echoed value is also what every fence in `/finalize` pins, so a publish or a +reject can only ever land on the lease the caller proved it holds. + +Both paths share `decideSource` (provenance and idempotency), so a session/Bearer caller can +never choose `source` or `sourceRef` on either, and `uploadId` is scoped to the authenticated +user on both. Both new paths are on the proxy's exact-match bypass and both refuse a +`next-action` dispatch with 403. + +### CUTOVER SEQUENCE — do these in this order (2026-09-02) + +The hazard this order exists to prevent: v2's QuickBooks identity for an +email/chat/mobile/web row is the intake UUID, which v1 never saw. QBO's DocNumber +idempotency therefore CANNOT recognise a Purchase v1 already created for the same +document. Run both pipelines live at once, or replay the shadow backlog through v2, and +those receipts book twice on real books. + +Drive rows are the exception: v2 books them under the Drive file id, which IS v1's +identity, so an overlap on a Drive-sourced file is idempotent. That is not enough to make +an overlap safe in general. + +1. **Flip the Apps Script to forwarder mode** (`V2_FORWARD=true`). It now COPIES bytes to + `/api/receipts/intake` and still books everything itself. ProBuild is in dry-run: + it reads, dedups and routes, and books nothing. +2. **Run the shadow week.** Gate on §8: 5 consecutive days where every archived v1 file has + a v2 row agreeing on vendor/date/total, and no v2 row stuck in RECEIVED over an hour. +3. **Flip the Apps Script to `V2_LIVE=true`.** It now MOVES files to `_Forwarded` instead of + booking them. v1 stops writing to QuickBooks. ProBuild is still in dry-run, so for this + window NOTHING books — that is intended and it is why the window is short. +4. **Confirm zero v1 bookings for 24 hours.** Watch the Automation register and QBO. This + is the step that makes the next one safe: it proves v1 is out of the books before v2 + enters them, so the two can never both create a Purchase for one document. +4a. **Record the boundary.** When step 3 happens, write the instant v1 stopped booking into + the `cutoverV1StoppedAt` AutomationSetting row (or the `CUTOVER_V1_STOPPED_AT` env var) as + an ISO timestamp. This is the ONLY input that separates "v1 booked it" from "nobody booked + it", and nothing in the database can infer it. +5. **Only then set `RECEIPT_INTAKE_DRYRUN=false`.** On its first pass the worker splits the + shadow backlog. The boundary narrows the CANDIDATES; **evidence** decides each one: + - before the boundary AND provably booked by v1 -> `SHADOW_DONE` / `booked-by-v1`. + Terminal; v2 never books these. Evidence is either an `AutomationEvent` + (`kind: receipt-push`, status `created`/`already-exists`) whose `driveFileId` matches + the row — v1's pushes go through ProBuild's create route, which logs them — or the + forwarder sending `archivedByV1: true` on the forward. + - before the boundary, NO evidence, and a **Drive** row -> handed to v2. Safe precisely + because a Drive row books under the **Drive file id**, so if v1 did book it after all, + QBO's DocNumber/requestid idempotency collapses the two into one Purchase. + - before the boundary, NO evidence, and **not** a Drive row -> `SHADOW_QUARANTINE`. + There is no shared identity here: v2 would book under the intake UUID, which v1 never + saw, so a duplicate would go through silently. Booking risks double-paying; retiring + risks losing a real expense. Terminal, never auto-requeued. **Phase 1b follow-up, not + built here:** the design is a Receipts tab with a "book anyway" action for whoever has + checked QuickBooks — same deferral as `NEEDS_JOB` and `NEEDS_REVIEW` rows, which also + have no review UI in Phase 1 (see §3's GET /api/receipts/intake note: the Phase 2 + `/automation` Receipts tab is the intended consumer). Until that ships, rows in any of + these three states are visible via the `ReceiptIntake` table directly (or + `GET /api/receipts/intake?state=`) and via the pipeline-health report, which counts + each of them separately: NEEDS_REVIEW (`intake.needsReview`), NEEDS_JOB + (`intake.unassigned`) and SHADOW_QUARANTINE (`intake.quarantined`, reason + `receipt-quarantine:` and its own digest line). The quarantine count carries no age + threshold, because the state is terminal the instant it is written — nothing is coming + to move it on. + - after the boundary -> handed to v2. v1 had already stopped, so nobody booked these. + With no boundary recorded in live mode the worker **halts the entire pass before + claiming anything** and logs `cutover-boundary-missing`. Not just the retire: booking + anything while we cannot tell what v1 already booked is the double-booking this whole + mechanism exists to prevent. + +Retired rows keep their read results and dedup keys, so a post-cutover resend of a +shadow-week receipt still collides with them and is caught as a duplicate. + +**Rolling back** after step 5 means turning `V2_LIVE` off again and `RECEIPT_INTAKE_DRYRUN` +back on. Rows received while v2 was live are already booked and stay `BOOKED`; v1 will not +re-book them, because its own `_Forwarded` move already took those files out of its path. + +Three things a human must do before this can leave shadow mode: + +- Set **both** `RECEIPT_INTAKE_SECRET` (new, independent of `RECEIPT_INGEST_SECRET`) and + `RECEIPT_ARCHIVE_SECRET` in Vercel — `authenticateIntake` requires both to be present and + to differ (§ "Two machine secrets, not one"): a caller presenting either value is refused + outright when its variable is unset, and setting them to the *same* value is refused too, + since that would silently re-merge the two capabilities it exists to keep apart. Give the + matching value to each consumer as a Script Property — `RECEIPT_INTAKE_SECRET` to the + ingest forwarders (drive/email/chat), `RECEIPT_ARCHIVE_SECRET` to the nightly Drive mirror + that polls `GET /api/receipts/intake?state=BOOKED|ARCHIVED`. Missing `RECEIPT_ARCHIVE_SECRET` + specifically means the archive mirror gets a blanket 401 from cutover day one, silently — + nothing else exercises that path pre-launch to surface the gap. +- Re-run `node scripts/snapshot-prisma-blind-spots.mjs --write` against production AFTER + `scripts/apply-receipt-intake.mjs` has run there. The new partial index and CHECK + constraint were added to `prisma/prisma-blind-spots.json` by hand (the snapshotter needs + a live production connection, which this branch never had), so their rendered + definitions are asserted, not observed. CI's `migrations` job is what will catch a + mismatch. + ## 8. Shadow-week gate - `RECEIPT_INTAKE_DRYRUN` unset/true: every row gets `dryRun=true` — reader, dedup, and @@ -387,9 +797,12 @@ All gated on Script Property `V2_FORWARD === "true"`; all send `x-receipt-intake 1. **Public-bypass route**: `/api/receipts/intake` bypasses the proxy, so the handler is the only gate. Mitigated by the fail-closed secret check + the 401 e2e matrix; Codex must review the auth block specifically. (Risk to watch, no decision needed.) -2. **Expense.amount = pre-tax when tax is split** (mirrors the QBO COGS line under the - reseller-permit rule in sendToQBOviaAPI.js). Confirm pre-tax is the job-cost number you - want feeding variance reports. +2. ~~**Expense.amount = pre-tax when tax is split**~~ **RESOLVED 2026-09-01: GROSS.** + Justin's call. `Expense.amount` is the total paid, tax INCLUDED, matching what the + QBO-imported expenses in `lib/qbo-expense-sync.ts` already record. The QBO Purchase + still splits the tax onto its own reclaimable account — that is unchanged and it is + what the reseller-permit filing reads. `ReceiptIntake.taxCents` keeps the split so + Phase 3 can add `Expense.taxAmount`. No open question here. 3. **Archive via nightly Apps Script mirror** (§6) instead of a Drive service account — confirm, or provision a service account now if same-hour archiving matters to Marge. 4. **HEIC**: stored and read fine (Gemini accepts image/heic), but the Phase 2 queue page diff --git a/e2e/receipt-intake.spec.ts b/e2e/receipt-intake.spec.ts new file mode 100644 index 000000000..6adc34ae4 --- /dev/null +++ b/e2e/receipt-intake.spec.ts @@ -0,0 +1,1954 @@ +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { PrismaClient } from "@prisma/client"; +import { createHash } from "node:crypto"; + +/** + * POST/GET /api/receipts/intake — request-level auth matrix and idempotency. + * + * `/api/receipts/intake` is on the proxy's EXACT-match public bypass + * (src/proxy.ts), which means the route handler is the only thing standing + * between an anonymous caller and a write. Source-reading proves nothing about + * that; these tests drive the real HTTP surface against the throwaway CI + * Postgres (data.setup.ts guards prod — docs/TESTING.md). + * + * Shaped after e2e/portal-estimate-access.spec.ts and + * e2e/deposit-ingest.spec.ts: every negative case asserts a JSON 401/403 and + * NOT a 307 to /login, because a redirect is what a machine caller silently + * mis-reads as "try again later" forever. + * + * NOT covered here, deliberately: the "RECEIPT_INTAKE_SECRET is unset" case. + * A spec cannot unset an env var on the server process it is talking to, so + * that fail-closed branch is pinned as a unit test instead — + * tests/receipt-intake-auth.test.ts, "the secret check fails CLOSED when the + * env var is unset or empty". + * + * Auth: RECEIPT_INTAKE_SECRET must be set for the server under test. CI wires + * it as a literal in .github/workflows/ci.yml (nothing external depends on the + * value), same pattern as DEPOSIT_INGEST_SECRET. + */ + +const prisma = new PrismaClient(); +/** + * THE LEASE GENERATION /finalize NOW REQUIRES. + * + * /start returns `uploadLease` with every URL it issues and /finalize refuses + * any call that does not echo the row's CURRENT one (409 `lease-stale`), so a + * delayed finalizer can no longer adopt a lease that was issued after it + * started. Tests whose subject is something else read the live value here. + * + * Rows seeded through the single-shot POST never had a signed URL and so carry + * no generation; those get one minted, which is the same shape /start would + * have written and keeps each test about its own subject. + */ +async function leaseOf(id: string): Promise { + const row = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { uploadLeaseNonce: true }, + }); + if (row?.uploadLeaseNonce) return row.uploadLeaseNonce; + const minted = `e2e-lease-${id}`; + await prisma.receiptIntake.update({ where: { id }, data: { uploadLeaseNonce: minted } }); + return minted; +} + +const INTAKE_PATH = "/api/receipts/intake"; +const SECRET = process.env.RECEIPT_INTAKE_SECRET || ""; +// The archive mirror holds a DIFFERENT key: it may read BOOKED/ARCHIVED rows and +// report what it archived, and nothing else. Cross-use is a 403. +const ARCHIVE_SECRET = process.env.RECEIPT_ARCHIVE_SECRET || ""; + +// The e2e test project from data.setup.ts, and the phase that belongs to it +// (via the approved mobile estimate). A phase is only valid against its own job, +// so any spec sending a costCodeId has to send this project too. +const PROJECT_ID = "cmml6vt3y000lpwrh0p9p3k12"; + +// One prefix for everything this file creates, so teardown can be exact. +const REF_PREFIX = "drive:e2e-intake-"; +const FILE_ID = `${Date.now()}-a`; +const SOURCE_REF = `${REF_PREFIX}${FILE_ID}`; + +// Rows created with a SERVER-minted sourceRef (web:) can't be found by +// the prefix, so they are tracked explicitly for teardown. +const minted: string[] = []; + +// A real 1x1 PNG: the endpoint decides the stored mime on the BYTES, so a +// placeholder string would be refused (which is itself asserted below). +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; +// A DIFFERENT 1x1 PNG (black, not white). Same format, different bytes — which +// is the whole point of the sourceRef-conflict case below. +const OTHER_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +function intakeBody(overrides: Record = {}) { + return JSON.stringify({ + source: "drive", + sourceRef: SOURCE_REF, + fileBase64: PNG_BASE64, + mimeType: "image/png", + fileName: "e2e-receipt.png", + ...overrides, + }); +} + +async function postIntake( + request: APIRequestContext, + data: string, + headers: Record = { "x-receipt-intake-secret": SECRET }, +) { + const res = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json", ...headers }, + data, + maxRedirects: 0, // a 307 to /login must FAIL this suite, not be followed + }); + let body: any = null; + try { body = await res.json(); } catch { /* non-JSON body is itself a failure signal */ } + return { res, body }; +} + +test.beforeAll(async () => { + expect( + SECRET, + "RECEIPT_INTAKE_SECRET must be set for the server under test (ci.yml sets it; locally export it before `npm run dev`)", + ).toBeTruthy(); + await prisma.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: REF_PREFIX } } }); +}); + +test.afterAll(async () => { + await prisma.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: REF_PREFIX } } }); + if (minted.length) await prisma.receiptIntake.deleteMany({ where: { id: { in: minted } } }); + await prisma.$disconnect(); +}); + +test.describe("intake auth is fail-closed", () => { + test("no credentials at all is a JSON 401, never a redirect to /login", async ({ playwright }) => { + const anonymous = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const { res, body } = await postIntake(anonymous, intakeBody({ sourceRef: `${REF_PREFIX}anon` }), {}); + expect(res.status()).toBe(401); + expect(res.headers().location, "a redirect here would look like a retryable failure to a bot").toBeUndefined(); + expect(body).toMatchObject({ ok: false, reason: "unauthorized" }); + await anonymous.dispose(); + }); + + test("a BOGUS session cookie is 401, not a pass", async ({ playwright }) => { + // The getclients-auth-gate lesson: a dev-auth fallback (or a gate that + // only checks for the presence of a cookie) hides exactly this hole. + const forged = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { + cookies: [{ + name: "next-auth.session-token", + value: "not-a-real-jwt", + domain: "localhost", + path: "/", + expires: -1, + httpOnly: true, + secure: false, + sameSite: "Lax" as const, + }], + origins: [], + }, + }); + const { res } = await postIntake(forged, intakeBody({ sourceRef: `${REF_PREFIX}bogus` }), {}); + expect(res.status()).toBe(401); + expect(res.headers().location).toBeUndefined(); + await forged.dispose(); + }); + + test("a WRONG secret is refused outright, and does not fall through to the session", async ({ request }) => { + // `request` carries the ADMIN storage state. A stale forwarder secret + // must still be a 401 — otherwise a rotated secret would silently keep + // working from any browser that happened to be signed in. + const { res, body } = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}wrong` }), { + "x-receipt-intake-secret": "definitely-not-the-secret", + }); + expect(res.status()).toBe(401); + expect(body).toMatchObject({ ok: false, reason: "unauthorized" }); + }); + + test("an empty secret header is not a bypass", async ({ playwright }) => { + const anonymous = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const { res } = await postIntake(anonymous, intakeBody({ sourceRef: `${REF_PREFIX}empty` }), { + "x-receipt-intake-secret": "", + }); + expect(res.status()).toBe(401); + await anonymous.dispose(); + }); +}); + +test.describe("intake POST", () => { + test("the same sourceRef twice yields ONE row and the SAME id", async ({ request }) => { + const first = await postIntake(request, intakeBody()); + expect(first.res.status(), JSON.stringify(first.body)).toBe(200); + expect(first.body.ok).toBe(true); + expect(first.body.state).toBe("RECEIVED"); + expect(first.body.sourceRef).toBe(SOURCE_REF); + // Shadow week: dry-run is the default and is captured per row. + expect(first.body.dryRun).toBe(true); + + const second = await postIntake(request, intakeBody()); + expect(second.res.status()).toBe(200); + expect(second.body.alreadyReceived).toBe(true); + expect(second.body.id).toBe(first.body.id); + + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: SOURCE_REF } }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(first.body.id); + // The row is only published to the worker AFTER its object lands. A row + // still in STAGING here would mean the claim could pick up a receipt + // whose file does not exist and park it "file-missing". + expect(rows[0].state).toBe("RECEIVED"); + expect(rows[0].mimeType).toBe("image/png"); + expect(rows[0].fileSha256).toHaveLength(64); + expect(rows[0].storagePath).toBe(`receipts/intake/${first.body.id}.png`); + }); + + test("a publish that failed after a successful upload RESUMES on the next retry", async ({ request }) => { + // The gap this closes: upload lands, the STAGING -> RECEIVED update then + // fails (a connection reset between two round trips is not rare). The + // object exists, the row does not point at it, and nothing would ever + // fix that — STAGING is invisible to the worker's claim by design, so + // the row would sit until the 15-minute sweeper wrongly declared its + // file missing. + const ref = `${REF_PREFIX}resume`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + + // Rewind to exactly the half-state a failed publish leaves behind: the + // object is in the bucket, the row is still STAGING. + await prisma.receiptIntake.update({ where: { id: created.body.id }, data: { state: "STAGING" } }); + + const retry = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(retry.res.status()).toBe(200); + expect(retry.body.state).toBe("RECEIVED"); + expect(retry.body.id).toBe(created.body.id); + + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: ref } }); + expect(rows).toHaveLength(1); + expect(rows[0].state).toBe("RECEIVED"); + }); + + test("a STAGING row whose object is NOT there yet answers 202, not 200", async ({ request }) => { + // A concurrent request is mid-upload, or the last one died before + // storing anything. 200 would promise a queued document that does not + // exist; 202 tells the caller to re-poll. The 15-minute sweeper handles + // the case where it never lands. + const ref = `${REF_PREFIX}staging-nofile`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + + // A STAGING row pointing at a path nothing was ever written to. + await prisma.receiptIntake.update({ + where: { id: created.body.id }, + data: { state: "STAGING", storagePath: `receipts/intake/${created.body.id}-never-uploaded.png` }, + }); + + // The replay carries the bytes again, so the orphan is HEALED rather + // than merely reported: stored and republished. Never a 202 — the + // forwarders retry only non-2xx, so "accepted" for a document we do not + // have would let a Drive script delete its only copy. + const retry = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(retry.res.status()).toBe(200); + expect(retry.body.recovered).toBe(true); + expect(retry.body.state).toBe("RECEIVED"); + expect(retry.body.id).toBe(created.body.id); + const healed = await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }); + expect(healed?.state).toBe("RECEIVED"); + }); + + test("a machine caller MUST supply its own sourceRef", async ({ request }) => { + const { res, body } = await postIntake(request, JSON.stringify({ + source: "drive", fileBase64: PNG_BASE64, mimeType: "image/png", + })); + expect(res.status()).toBe(400); + expect(body.reason).toBe("missing-sourceRef"); + }); + + test("reusing a sourceRef for DIFFERENT bytes is 409, and stores nothing", async ({ request }) => { + // The dangerous case: answering 200 would tell the forwarder its NEW + // receipt was accepted when nothing was stored, and that receipt would + // never be booked. + const ref = `${REF_PREFIX}sha-conflict`; + const first = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(first.res.status()).toBe(200); + + const second = await postIntake(request, intakeBody({ sourceRef: ref, fileBase64: OTHER_PNG_BASE64 })); + expect(second.res.status()).toBe(409); + expect(second.body).toMatchObject({ error: "sourceRef-conflict", existingId: first.body.id }); + + // Exactly one row, still pointing at the ORIGINAL bytes. + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: ref } }); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(first.body.id); + + // And the row's stored object is the one the FIRST request wrote — the + // conflicting call must never touch storage. + expect(rows[0].storagePath).toBe(`receipts/intake/${first.body.id}.png`); + }); + + test("a different-SHA 409 leaks NOTHING to a caller who may not read the row", async ({ request, playwright }) => { + // `existingId` is a real identifier for someone else's document. Handing + // it to a caller that fails the read check turns the 409 into an oracle: + // guess a sourceRef, learn it exists, and get a usable id back. + // + // A shared-secret caller is scoped to its OWN namespace — the forwarders + // are separate scripts, and the chat one should learn nothing about the + // Drive pipeline's rows. + const ref = `${REF_PREFIX}ns-drive`; + const seeded = await postIntake(request, intakeBody({ source: "drive", sourceRef: ref })); + expect(seeded.res.status()).toBe(200); + + const machine = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + // FIRST LINE OF DEFENCE: declaring `chat` while naming a `drive:` ref + // is refused outright, before the row is ever looked up. So a + // cross-namespace probe cannot even reach the conflict handler. + const crossNamespace = await machine.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "chat", sourceRef: ref, + fileBase64: OTHER_PNG_BASE64, mimeType: "image/png", + }), + maxRedirects: 0, + }); + expect(crossNamespace.status()).toBe(400); + expect((await crossNamespace.json()).reason).toBe("sourceRef-namespace-mismatch"); + + // SECOND LINE: the conflict handler checks the row's OWN `source` too, + // so a row whose stored source disagrees with its prefix — a legacy row + // from before the prefix rule existed — still leaks nothing. Seeded + // directly, because the route can no longer create that shape. + const legacyRef = `${REF_PREFIX}legacy-mismatch`; + const legacy = await postIntake(request, intakeBody({ source: "drive", sourceRef: legacyRef })); + expect(legacy.res.status()).toBe(200); + await prisma.receiptIntake.update({ where: { id: legacy.body.id }, data: { source: "chat" } }); + + const probe = await machine.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "drive", sourceRef: legacyRef, + fileBase64: OTHER_PNG_BASE64, mimeType: "image/png", + }), + maxRedirects: 0, + }); + expect(probe.status()).toBe(409); + const body = await probe.json(); + expect(body.error).toBe("sourceRef-conflict"); + expect(body, "no id for a caller outside the row's namespace").not.toHaveProperty("existingId"); + await machine.dispose(); + + // The row's OWN namespace still gets the id, so the real forwarder can + // act on the conflict. + const sameNamespace = await postIntake(request, intakeBody({ + source: "drive", sourceRef: ref, fileBase64: OTHER_PNG_BASE64, + })); + expect(sameNamespace.res.status()).toBe(409); + expect(sameNamespace.body.existingId).toBe(seeded.body.id); + }); + + test("the same uploadId from one user is ONE row; a raw sourceRef is still refused", async ({ request }) => { + // A phone on a bad connection needs a safe retry. A minted uuid makes + // every retry a NEW document, so a crew member tapping Send twice on a + // spinner books the same receipt twice. `uploadId` is the client's own + // idempotency token — and it is scoped to the authenticated user + // server-side, so two people cannot collide on one uuid and nobody can + // reach another user's row by guessing one. + const uploadId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + const body = JSON.stringify({ fileBase64: PNG_BASE64, mimeType: "image/png", uploadId }); + const post = () => request.post(INTAKE_PATH, { + headers: { "content-type": "application/json" }, + data: body, + maxRedirects: 0, + }); + + const first = await post(); + expect(first.status()).toBe(200); + const firstBody = await first.json(); + minted.push(firstBody.id); + // Scoped to the user, so the uuid alone is not the key. + expect(firstBody.sourceRef).toMatch(/^web:[^:]+:3f2504e0-4f89-41d3-9a0c-0305e82c3301$/); + expect(firstBody.sourceRef).not.toBe(`web:${uploadId}`); + + const second = await post(); + expect(second.status()).toBe(200); + const secondBody = await second.json(); + expect(secondBody.id).toBe(firstBody.id); + expect(secondBody.alreadyReceived).toBe(true); + + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: firstBody.sourceRef } }); + expect(rows).toHaveLength(1); + + // A non-UUID token is refused rather than used as a free-text key. + const junk = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ fileBase64: PNG_BASE64, mimeType: "image/png", uploadId: "not-a-uuid" }), + maxRedirects: 0, + }); + expect(junk.status()).toBe(400); + expect((await junk.json()).reason).toBe("invalid-uploadId"); + }); + + test("a secret caller's sourceRef must live in the namespace it declared", async ({ request }) => { + // Without this a chat forwarder could write `drive:` and collide + // with — or pre-empt — the Drive pipeline's key for a file it does not + // own, and `drive` rows are the ones that book under the Drive fileId, + // i.e. the QBO DocNumber. + const { res, body } = await postIntake(request, intakeBody({ + source: "chat", sourceRef: `${REF_PREFIX}wrongns`, + })); + expect(res.status()).toBe(400); + expect(body.reason).toBe("sourceRef-namespace-mismatch"); + }); + + test("a session caller may not choose its own source or sourceRef", async ({ request }) => { + // `source` is provenance and it feeds booking identity: a `drive` row + // books under the Drive fileId, so a forged source could aim a QBO + // DocNumber at another document's idempotency key. + const forgedRef = await postIntake(request, JSON.stringify({ + source: "web", sourceRef: `${REF_PREFIX}forged`, fileBase64: PNG_BASE64, mimeType: "image/png", + }), {}); + expect(forgedRef.res.status()).toBe(400); + expect(forgedRef.body.reason).toBe("sourceRef-not-allowed"); + + const forgedSource = await postIntake(request, JSON.stringify({ + source: "drive", fileBase64: PNG_BASE64, mimeType: "image/png", + }), {}); + expect(forgedSource.res.status()).toBe(400); + expect(forgedSource.body.reason).toBe("invalid-source"); + }); + + test("a session upload with no uploadId is keyed by CONTENT, so a bare retry is idempotent", async ({ request }) => { + // The OLD behavior minted a random uuid here, so a retry with no + // client-supplied uploadId — a flaky connection, a double-tap on a + // slow spinner — was accepted as a brand new receipt every time. The + // fix derives a STABLE key from the bytes themselves, scoped to the + // user, so an identical retry collides with the row it already made. + const post = () => request.post(INTAKE_PATH, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ fileBase64: PNG_BASE64, mimeType: "image/png", fileName: "web.png" }), + maxRedirects: 0, + }); + + const first = await post(); + expect(first.status()).toBe(200); + const firstBody = await first.json(); + minted.push(firstBody.id); + const sha256 = createHash("sha256").update(Buffer.from(PNG_BASE64, "base64")).digest("hex"); + expect(firstBody.sourceRef).toMatch(/^session:[^:]+:[0-9a-f]{64}$/); + expect(firstBody.sourceRef.endsWith(`:${sha256}`)).toBe(true); + + const second = await post(); + expect(second.status()).toBe(200); + const secondBody = await second.json(); + expect(secondBody.id).toBe(firstBody.id); + expect(secondBody.alreadyReceived).toBe(true); + + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: firstBody.sourceRef } }); + expect(rows).toHaveLength(1); + }); + + test("a secret caller may not declare a USER source", async ({ request }) => { + const { res, body } = await postIntake(request, intakeBody({ + source: "web", sourceRef: `${REF_PREFIX}websecret`, + })); + expect(res.status()).toBe(400); + expect(body.reason).toBe("invalid-source"); + }); + + test("deterministic bad input is terminal, not a 500 the forwarder retries forever", async ({ request }) => { + // A malformed REQUEST is a 400. An unsupported FORMAT is a 415 — the + // request is well-formed, the file is simply one QuickBooks cannot + // attach, and the body names what to send instead. Both are terminal: + // what must never happen is a 5xx the forwarder retries forever. + const cases: [string, number, string][] = [ + [intakeBody({ source: "carrier-pigeon", sourceRef: `${REF_PREFIX}src` }), 400, "invalid-source"], + [JSON.stringify({ source: "drive", sourceRef: `${REF_PREFIX}nofile` }), 400, "missing-file"], + // Base64 of "hello" — not a document format we can read. + [intakeBody({ sourceRef: `${REF_PREFIX}junk`, fileBase64: "aGVsbG8=", mimeType: "image/png" }), 415, "unsupported-file-type"], + ]; + for (const [data, status, name] of cases) { + const { res, body } = await postIntake(request, data); + expect(res.status(), name).toBe(status); + // 400s carry `reason`; the 415 carries `error` plus a human `reason` + // and the accepted list. + expect(body.reason ?? body.error, name).toBeTruthy(); + expect([body.reason, body.error], name).toContain(name); + if (status === 415) expect(body.accepted, name).toContain("application/pdf"); + } + }); + + test("a declared mime cannot override the bytes", async ({ request }) => { + // Claiming application/pdf over PNG bytes must store image/png. + const ref = `${REF_PREFIX}sniff`; + const { res, body } = await postIntake(request, intakeBody({ sourceRef: ref, mimeType: "application/pdf" })); + expect(res.status()).toBe(200); + const row = await prisma.receiptIntake.findUnique({ where: { id: body.id } }); + expect(row?.mimeType).toBe("image/png"); + }); +}); + +test.describe("intake GET", () => { + test("an ADMIN session can read the queue", async ({ request }) => { + await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}list` })); + const res = await request.get(`${INTAKE_PATH}?state=RECEIVED&take=200`, { maxRedirects: 0 }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.rows.some((r: any) => r.sourceRef === `${REF_PREFIX}list`)).toBe(true); + // The raw model output never leaves the server. + expect(body.rows[0]).not.toHaveProperty("readJson"); + }); + + test("the archive mirror can poll BOOKED, and sees only what it needs", async ({ request, playwright }) => { + // Seed a BOOKED row so the field set is asserted against a real payload + // rather than an empty list. + const ref = `${REF_PREFIX}mirror`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + await prisma.receiptIntake.update({ + where: { id: created.body.id }, + data: { state: "BOOKED", vendor: "Lowes", totalCents: 36498, lastError: "should-not-be-visible" }, + }); + + const machine = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const res = await machine.get(`${INTAKE_PATH}?state=BOOKED`, { + headers: { "x-receipt-intake-secret": ARCHIVE_SECRET }, + maxRedirects: 0, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + const row = body.rows.find((r: any) => r.id === created.body.id); + expect(row).toBeTruthy(); + expect(row.vendor).toBe("Lowes"); + expect(row.totalCents).toBe(36498); + // Least privilege: a script that only copies files to Drive has no need + // for error text, content hashes, or who uploaded it. + for (const forbidden of ["lastError", "fileSha256", "createdById", "dedupWeakKey", "dedupStrongKey", "attempts", "readJson"]) { + expect(row, forbidden).not.toHaveProperty(forbidden); + } + await machine.dispose(); + }); + + test("the shared secret cannot sweep any state it likes", async ({ playwright }) => { + const machine = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + for (const state of ["NEEDS_REVIEW", "RECEIVED", "READ", ""]) { + const res = await machine.get(`${INTAKE_PATH}${state ? `?state=${state}` : ""}`, { + headers: { "x-receipt-intake-secret": ARCHIVE_SECRET }, + maxRedirects: 0, + }); + expect(res.status(), state || "(no state)").toBe(400); + } + // ARCHIVED is allowed — the mirror re-checks what it already copied. + const archived = await machine.get(`${INTAKE_PATH}?state=ARCHIVED`, { + headers: { "x-receipt-intake-secret": ARCHIVE_SECRET }, + maxRedirects: 0, + }); + expect(archived.status()).toBe(200); + await machine.dispose(); + }); + + test("a staff user without a bookkeeping role gets 403, not a redirect", async ({ playwright }) => { + // contract-user.json is an EMPLOYEE (e2e/auth-contract.setup.ts). An + // ADMIN session can never reach this branch, so this second storage + // state IS the test. + const employee = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: "e2e/.auth/contract-user.json", + }); + const res = await employee.get(INTAKE_PATH, { maxRedirects: 0 }); + expect(res.status()).toBe(403); + expect(res.headers().location).toBeUndefined(); + await employee.dispose(); + }); + + test("no credentials is 401", async ({ playwright }) => { + const anonymous = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const res = await anonymous.get(INTAKE_PATH, { maxRedirects: 0 }); + expect(res.status()).toBe(401); + expect(res.headers().location).toBeUndefined(); + await anonymous.dispose(); + }); +}); + +test.describe("archive callback", () => { + test("it is secret-only and refuses a row that is not BOOKED", async ({ request, playwright }) => { + const ref = `${REF_PREFIX}archive`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + + const anonymous = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const unauthed = await anonymous.post(`${INTAKE_PATH}/${id}/archived`, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ driveFileId: "DRIVE1FILE" }), + maxRedirects: 0, + }); + expect(unauthed.status()).toBe(401); + expect(unauthed.headers().location).toBeUndefined(); + + // A session, however privileged, is NOT a substitute: only the mirror + // can know that a file now exists in Drive. + const sessionAttempt = await request.post(`${INTAKE_PATH}/${id}/archived`, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ driveFileId: "DRIVE1FILE" }), + maxRedirects: 0, + }); + expect(sessionAttempt.status()).toBe(401); + + const notBooked = await anonymous.post(`${INTAKE_PATH}/${id}/archived`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": ARCHIVE_SECRET }, + data: JSON.stringify({ driveFileId: "DRIVE1FILE" }), + maxRedirects: 0, + }); + expect(notBooked.status()).toBe(409); + + await prisma.receiptIntake.update({ where: { id }, data: { state: "BOOKED" } }); + const archive = (driveFileId: string) => anonymous.post(`${INTAKE_PATH}/${id}/archived`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": ARCHIVE_SECRET }, + data: JSON.stringify({ driveFileId }), + maxRedirects: 0, + }); + + const ok = await archive("DRIVE1FILE"); + expect(ok.status()).toBe(200); + const row = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(row?.state).toBe("ARCHIVED"); + expect(row?.archiveDriveFileId).toBe("DRIVE1FILE"); + + // IDEMPOTENT REPLAY. The mirror POSTs after writing the Drive file, so a + // lost response leaves it holding a file it cannot confirm. Re-sending + // the same id is the correct retry: a 409 would make the script treat + // its own successful archive as a failure. + const replay = await archive("DRIVE1FILE"); + expect(replay.status()).toBe(200); + expect((await replay.json()).alreadyArchived).toBe(true); + + // Concurrent identical callbacks: both read BOOKED, the winner archives + // and the loser's conditional update matches nothing. The loser must + // re-read and report success — a 409 there made the mirror treat its + // OWN successful archive as a failure. + const [a, b] = await Promise.all([archive("DRIVE1FILE"), archive("DRIVE1FILE")]); + expect([a.status(), b.status()]).toEqual([200, 200]); + + // A DIFFERENT file id on an archived row is not a replay — two Drive + // copies exist and somebody has to say which one counts. + const conflicting = await archive("DRIVE2FILE"); + expect(conflicting.status()).toBe(409); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.archiveDriveFileId).toBe("DRIVE1FILE"); + + await anonymous.dispose(); + }); + + test("an unknown id is 404", async ({ playwright }) => { + const machine = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const res = await machine.post(`${INTAKE_PATH}/no-such-row/archived`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": ARCHIVE_SECRET }, + data: JSON.stringify({ driveFileId: "DRIVE1FILE" }), + maxRedirects: 0, + }); + expect(res.status()).toBe(404); + await machine.dispose(); + }); + + test("an implausible driveFileId is refused, and leaves the row untouched", async ({ request, playwright }) => { + // "Any non-empty string" let a single stray character become the + // permanent archive identity for a row. This value is held to the SAME + // shape a `drive` sourceRef's tail is (intake-core.ts SOURCE_REF_PATTERNS), + // since it lands in the same place: logs, equality checks, and + // `archiveDriveFileId`. + const ref = `${REF_PREFIX}archive-invalid`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + await prisma.receiptIntake.update({ where: { id }, data: { state: "BOOKED" } }); + + const machine = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + const post = (driveFileId: unknown) => machine.post(`${INTAKE_PATH}/${id}/archived`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": ARCHIVE_SECRET }, + data: JSON.stringify({ driveFileId }), + maxRedirects: 0, + }); + + const tooShort = await post("x"); + expect(tooShort.status()).toBe(400); + expect((await tooShort.json()).reason).toBe("invalid-driveFileId"); + + const tooLong = await post("a".repeat(200)); + expect(tooLong.status()).toBe(400); + expect((await tooLong.json()).reason).toBe("invalid-driveFileId"); + + // Neither attempt moved the row at all. + const row = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(row?.state).toBe("BOOKED"); + expect(row?.archiveDriveFileId).toBeNull(); + + await machine.dispose(); + }); +}); + +test.describe("orphan recovery", () => { + test("replaying a row the sweeper already parked file-missing HEALS it", async ({ request }) => { + // The hole this closes: storage existence was checked only while the row + // was STAGING. Once the sweep flipped an orphan to + // NEEDS_REVIEW/file-missing, an identical replay got a cheerful 200 and + // the forwarder could delete its only copy of a receipt we did not have. + const ref = `${REF_PREFIX}swept-orphan`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + + // Exactly what the sweeper leaves behind. + await prisma.receiptIntake.update({ + where: { id: created.body.id }, + data: { + state: "NEEDS_REVIEW", + stateReason: "file-missing", + storagePath: `receipts/intake/${created.body.id}-gone.png`, + }, + }); + + const replay = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(replay.res.status()).toBe(200); + expect(replay.body.recovered).toBe(true); + + const row = await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }); + expect(row?.state).toBe("RECEIVED"); + expect(row?.stateReason).toBeNull(); + }); + + test("a BOOKED row whose object vanished is never rewritten by a replay", async ({ request }) => { + // A replay may heal an orphan, but it must not be able to reach into a + // row that already has a Purchase behind it. + const ref = `${REF_PREFIX}booked-orphan`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + await prisma.receiptIntake.update({ + where: { id: created.body.id }, + data: { state: "BOOKED", storagePath: `receipts/intake/${created.body.id}-gone.png` }, + }); + + const replay = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(replay.res.status()).toBe(409); + expect(replay.body.error).toBe("object-missing"); + expect((await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }))?.state).toBe("BOOKED"); + }); +}); + +test.describe("the two machine secrets are not interchangeable", () => { + test("the ingest key cannot read the queue, and the archive key cannot ingest", async ({ playwright }) => { + // One shared secret gave a script that only copies files to Drive the + // power to inject Purchases into the books, and gave the forwarders the + // power to enumerate every receipt. 403, not 401: the caller IS + // authenticated, it is holding the wrong program's key. + const machine = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: { cookies: [], origins: [] }, + }); + + const forwarderReadingQueue = await machine.get(`${INTAKE_PATH}?state=BOOKED`, { + headers: { "x-receipt-intake-secret": SECRET }, + maxRedirects: 0, + }); + expect(forwarderReadingQueue.status()).toBe(403); + + const mirrorIngesting = await machine.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": ARCHIVE_SECRET }, + data: intakeBody({ sourceRef: `${REF_PREFIX}wrongkey` }), + maxRedirects: 0, + }); + expect(mirrorIngesting.status()).toBe(403); + + const mirrorStartingUpload = await machine.post(`${INTAKE_PATH}/start`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": ARCHIVE_SECRET }, + data: JSON.stringify({ mimeType: "image/png", source: "drive", sourceRef: `${REF_PREFIX}wrongkey2` }), + maxRedirects: 0, + }); + expect(mirrorStartingUpload.status()).toBe(403); + + await machine.dispose(); + }); +}); + +test.describe("cutover retirement needs evidence, not just an old timestamp", () => { + test("a shadow row v1 provably booked is retired; one it never touched is handed to v2", async ({ request }) => { + // "Received before the boundary" says when the file ARRIVED, not that + // anything booked it. v1 skips documents constantly — a bad read, a + // park, a file it never picked up — and retiring those as + // "booked-by-v1" silently drops real expenses. + const boundary = new Date(Date.now() + 60_000); + const evidencedFile = `EVID-${Date.now()}`; + const orphanFile = `ORPH-${Date.now()}`; + + const evidenced = await postIntake(request, intakeBody({ sourceRef: `drive:${evidencedFile}` })); + const orphan = await postIntake(request, intakeBody({ sourceRef: `drive:${orphanFile}` })); + expect(evidenced.res.status()).toBe(200); + expect(orphan.res.status()).toBe(200); + minted.push(evidenced.body.id, orphan.body.id); + + // Both parked exactly as the shadow week leaves them. + await prisma.receiptIntake.updateMany({ + where: { id: { in: [evidenced.body.id, orphan.body.id] } }, + data: { state: "READ", dryRun: true }, + }); + + // Only ONE of them has v1's own booking event behind it. v1 pushes go + // through ProBuild's create route, which logs exactly this. + const event = await prisma.automationEvent.create({ + data: { + kind: "receipt-push", + status: "created", + source: "apps-script", + driveFileId: evidencedFile, + }, + }); + + try { + await prisma.automationSetting.upsert({ + where: { key: "cutoverV1StoppedAt" }, + update: { value: boundary.toISOString() }, + create: { key: "cutoverV1StoppedAt", value: boundary.toISOString() }, + }); + + // Drive the real cutover through the worker's own claim path. + const res = await request.get("/api/cron/receipt-intake-worker", { + headers: process.env.CRON_SECRET ? { authorization: `Bearer ${process.env.CRON_SECRET}` } : {}, + maxRedirects: 0, + }); + // Skip cleanly if the cron is secret-gated in this environment. + test.skip(res.status() === 401, "CRON_SECRET not available to the spec"); + expect(res.status()).toBe(200); + + const after = await prisma.receiptIntake.findMany({ + where: { id: { in: [evidenced.body.id, orphan.body.id] } }, + select: { id: true, state: true, stateReason: true, dryRun: true }, + }); + const byId = Object.fromEntries(after.map(r => [r.id, r])); + + expect(byId[evidenced.body.id].state).toBe("SHADOW_DONE"); + expect(byId[evidenced.body.id].stateReason).toBe("booked-by-v1"); + + // No evidence -> v2's to book. Safe because a Drive row books under + // the DRIVE FILE ID, so a v1/v2 overlap collapses to one Purchase. + expect(byId[orphan.body.id].state).not.toBe("SHADOW_DONE"); + expect(byId[orphan.body.id].dryRun).toBe(false); + } finally { + await prisma.automationEvent.delete({ where: { id: event.id } }).catch(() => {}); + await prisma.automationSetting.deleteMany({ where: { key: "cutoverV1StoppedAt" } }).catch(() => {}); + } + }); + + test("the forwarder can assert it already archived a file", async ({ request }) => { + // The second accepted form of evidence, for documents v1 handled before + // the create route existed to log them. + const ref = `${REF_PREFIX}archived-by-v1`; + const res = await postIntake(request, JSON.stringify({ + source: "drive", sourceRef: ref, fileBase64: PNG_BASE64, + mimeType: "image/png", archivedByV1: true, + })); + expect(res.res.status()).toBe(200); + const row = await prisma.receiptIntake.findUnique({ where: { id: res.body.id } }); + expect(row?.archivedByV1).toBe(true); + }); + + test("a SESSION caller cannot claim v1 already booked something", async ({ request }) => { + // That flag is what excuses v2 from booking a document. Only a + // shared-secret forwarder may assert it. + // + // A distinct uploadId here (rather than relying on the no-uploadId + // content key) keeps this row independent of the identical PNG_BASE64 + // bytes other session-auth tests in this file upload — this test is + // about archivedByV1, not about content-based idempotency. + const res = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ + fileBase64: PNG_BASE64, mimeType: "image/png", archivedByV1: true, + uploadId: "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed", + }), + maxRedirects: 0, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + minted.push(body.id); + const row = await prisma.receiptIntake.findUnique({ where: { id: body.id } }); + expect(row?.archivedByV1).toBe(false, "a browser upload can never claim v1 booked it"); + }); +}); + +test.describe("two-step upload: a reused key cannot swap the document", () => { + const startPath = `${INTAKE_PATH}/start`; + const sha = (b64: string) => createHash("sha256").update(Buffer.from(b64, "base64")).digest("hex"); + + test("SEQUENTIAL reuse with different bytes is refused before a URL is issued", async ({ request }) => { + // Caught at /start, not at /finalize: by then the caller would have + // uploaded receipt B over receipt A's object and A's bytes are gone. + const ref = `${REF_PREFIX}twostep-seq`; + const first = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }), + maxRedirects: 0, + }); + expect(first.status()).toBe(200); + const started = await first.json(); + minted.push(started.id); + expect(started.uploadUrl).toBeTruthy(); + + // Same key, same document — a plain retry resumes. + const resumed = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }), + maxRedirects: 0, + }); + expect(resumed.status()).toBe(200); + expect((await resumed.json()).id).toBe(started.id); + + // Same key, DIFFERENT document — refused. + const swapped = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(OTHER_PNG_BASE64), + }), + maxRedirects: 0, + }); + expect(swapped.status()).toBe(409); + expect((await swapped.json()).error).toBe("sourceRef-conflict"); + }); + + test("CONCURRENT starts on one key yield ONE row", async ({ request }) => { + const ref = `${REF_PREFIX}twostep-race`; + const body = JSON.stringify({ + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + const fire = () => request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: body, + maxRedirects: 0, + }); + + const results = await Promise.all([fire(), fire(), fire()]); + for (const r of results) expect(r.status()).toBe(200); + const ids = new Set(await Promise.all(results.map(async r => (await r.json()).id))); + expect(ids.size).toBe(1, "the unique index collapses the race to one row"); + + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: ref } }); + expect(rows).toHaveLength(1); + expect(rows[0].expectedSha256).toBe(sha(PNG_BASE64)); + minted.push(rows[0].id); + }); + + test("finalize refuses when the STORED bytes are not what /start was told", async ({ request }) => { + const ref = `${REF_PREFIX}twostep-sha`; + const started = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + // Declares a hash the bytes will never match. + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: "a".repeat(64) }), + maxRedirects: 0, + }); + expect(started.status()).toBe(200); + const { id, storagePath } = await started.json(); + minted.push(id); + + // Put REAL bytes at the path the row points at, as a direct upload would. + await prisma.receiptIntake.update({ where: { id }, data: { storagePath } }); + const seeded = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: intakeBody({ sourceRef: `${REF_PREFIX}twostep-sha-src` }), + maxRedirects: 0, + }); + expect(seeded.status()).toBe(200); + const seededRow = await prisma.receiptIntake.findUnique({ where: { id: (await seeded.json()).id } }); + minted.push(seededRow!.id); + await prisma.receiptIntake.update({ where: { id }, data: { storagePath: seededRow!.storagePath } }); + + const finalized = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(finalized.status()).toBe(409); + expect((await finalized.json()).error).toBe("sha-mismatch"); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.state).toBe("STAGING"); + }); + + test("SWEPT then re-uploaded: /start re-arms the row and /finalize publishes it", async ({ request }) => { + // End to end for the recovery the sweeper leaves behind. The old + // behaviour answered `alreadyReceived` for any non-STAGING row, which + // told the forwarder we held a receipt we did not hold — and it deletes + // its only copy on that answer — leaving the row parked forever with + // nothing to recover from. + const ref = `${REF_PREFIX}swept-restart`; + const first = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(OTHER_PNG_BASE64) }), + maxRedirects: 0, + }); + expect(first.status()).toBe(200); + const { id } = await first.json(); + minted.push(id); + + // Exactly what the stale-STAGING sweep leaves behind when the upload + // never landed -- INCLUDING A DEAD LEASE. + // + // The sweeper only ever picks rows whose `uploadUrlExpiresAt` is null or + // already past (see sweepStaleStaging's WHERE), so a `file-missing` park + // in production always has an expired lease. Leaving the fresh two-hour + // one this /start issued would be a state the sweeper cannot produce -- + // and since round 20 a LIVE lease is immutable, so the corrected-hash + // retry below would be answered 409 lease-conflict instead of re-armed. + // That refusal is correct for a live lease and is covered by its own + // case below; this one is about the recovery. + await prisma.receiptIntake.update({ + where: { id }, + data: { + state: "NEEDS_REVIEW", + stateReason: "file-missing", + uploadUrlExpiresAt: new Date(Date.now() - 60_000), + }, + }); + + // The client comes back with the correct document — a DIFFERENT hash + // from the one it first announced, which for a STAGING row would be a + // sourceRef-conflict. Here there are no verified bytes to protect. + const rearmed = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }), + maxRedirects: 0, + }); + expect(rearmed.status()).toBe(200); + const rearmedBody = await rearmed.json(); + expect(rearmedBody.id).toBe(id); + expect(rearmedBody.recovered).toBe(true, "a new URL, not alreadyReceived"); + expect(rearmedBody.uploadUrl).toBeTruthy(); + const armed = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(armed?.expectedSha256).toBe(sha(PNG_BASE64), "the new hash is what finalize will verify"); + expect(armed?.state).toBe("NEEDS_REVIEW", "still parked until the bytes actually land"); + + // "Upload": the spec cannot PUT to Supabase, so real bytes are put at a + // path by the single-shot route and the row is pointed at them — the + // same seeding trick the sha-mismatch case above uses. + const seeded = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: intakeBody({ sourceRef: `${REF_PREFIX}swept-restart-src` }), + maxRedirects: 0, + }); + expect(seeded.status()).toBe(200); + const seededRow = await prisma.receiptIntake.findUnique({ where: { id: (await seeded.json()).id } }); + minted.push(seededRow!.id); + await prisma.receiptIntake.update({ where: { id }, data: { storagePath: seededRow!.storagePath } }); + + const finalized = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(finalized.status()).toBe(200); + const done = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(done?.state).toBe("RECEIVED", "the swept row recovered all the way to published"); + expect(done?.stateReason).toBeNull(); + expect(done?.fileSha256).toBe(sha(PNG_BASE64)); + }); + + test("a parked row whose lease is STILL LIVE is refused, not re-armed", async ({ request }) => { + // The other half of the rule the case above depends on. A live lease's + // identity -- path, declared type, announced hash -- is immutable for + // its lifetime: re-arming it would bump the version, repath the row and + // rotate the generation while the first caller's signed URL still + // worked and still pointed at the object about to be orphaned. + const ref = `${REF_PREFIX}swept-live-lease`; + const first = await startWith(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(OTHER_PNG_BASE64), + }); + expect(first.status()).toBe(200); + const started = await first.json(); + minted.push(started.id); + + // Parked, but the lease this /start issued is untouched and live. + await prisma.receiptIntake.update({ + where: { id: started.id }, + data: { state: "NEEDS_REVIEW", stateReason: "file-missing" }, + }); + + const conflicting = await startWith(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + expect(conflicting.status()).toBe(409); + const body = await conflicting.json(); + expect(body.error).toBe("lease-conflict"); + expect(body.field).toBe("sha256"); + expect(body.retryable).toBe(true); + expect(body.leaseExpiresAt, "so the caller knows how long to wait").toBeTruthy(); + + // NOTHING MOVED: the first caller's URL is still the row's. + const row = await prisma.receiptIntake.findUnique({ where: { id: started.id } }); + expect(row?.storagePath).toBe(started.storagePath); + expect(row?.uploadLeaseNonce).toBe(started.uploadLease); + expect(row?.uploadLeaseVersion).toBe(1); + + // ...and the SAME retry succeeds once that lease has lapsed, which is + // what makes the refusal a wait rather than a dead end. + await prisma.receiptIntake.update({ + where: { id: started.id }, + data: { uploadUrlExpiresAt: new Date(Date.now() - 60_000) }, + }); + const rearmed = await startWith(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + expect(rearmed.status()).toBe(200); + const rearmedBody = await rearmed.json(); + expect(rearmedBody.kind).toBe("upload"); + expect(rearmedBody.recovered).toBe(true); + expect(rearmedBody.uploadLease).not.toBe(started.uploadLease); + }); + + test("a CHANGED file type against a live lease is refused the same way", async ({ request }) => { + const ref = `${REF_PREFIX}live-lease-mime`; + const first = await startWith(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + expect(first.status()).toBe(200); + const started = await first.json(); + minted.push(started.id); + + // Same document, same hash, DIFFERENT declared type. The path is derived + // from (id, leaseVersion, ext), so this cannot reuse the lease -- and it + // must not repath one that is live either. + const swapped = await startWith(request, { + source: "drive", sourceRef: ref, mimeType: "application/pdf", sha256: sha(PNG_BASE64), + }); + expect(swapped.status()).toBe(409); + const body = await swapped.json(); + expect(body.error).toBe("lease-conflict"); + expect(body.field).toBe("mime"); + + const row = await prisma.receiptIntake.findUnique({ where: { id: started.id } }); + expect(row?.storagePath).toBe(started.storagePath, "still the PNG path"); + expect(row?.uploadLeaseNonce).toBe(started.uploadLease); + }); + + test("a park a re-upload CANNOT fix still answers alreadyReceived", async ({ request }) => { + // The control. Only file-missing and sha-mismatch are recoverable; a + // row parked on a human's decision must not be handed a fresh URL that + // would let a client overwrite the document under review. + const ref = `${REF_PREFIX}swept-notrecoverable`; + const first = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }), + maxRedirects: 0, + }); + expect(first.status()).toBe(200); + const { id } = await first.json(); + minted.push(id); + + // The row must actually HOLD its document, because `alreadyReceived` is + // now answered from the bucket and not from the row alone — the + // forwarder deletes its only copy on that answer, so /start confirms + // the object is really there first. /start never carries bytes and this + // spec cannot PUT to a signed URL, so the object is seeded the same way + // the two cases above do it: real bytes stored by the single-shot + // route, and the row pointed at them. + const seeded = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: intakeBody({ sourceRef: `${REF_PREFIX}swept-notrecoverable-src` }), + maxRedirects: 0, + }); + expect(seeded.status()).toBe(200); + const seededRow = await prisma.receiptIntake.findUnique({ where: { id: (await seeded.json()).id } }); + minted.push(seededRow!.id); + const storagePath = seededRow!.storagePath; + + await prisma.receiptIntake.update({ + where: { id }, + data: { state: "NEEDS_REVIEW", stateReason: "vendor-mismatch", storagePath }, + }); + + const again = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }), + maxRedirects: 0, + }); + expect(again.status()).toBe(200); + const body = await again.json(); + expect(body.alreadyReceived).toBe(true); + expect(body.uploadUrl).toBeUndefined(); + const row = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(row?.storagePath).toBe(storagePath, "nothing was re-armed"); + expect(row?.stateReason).toBe("vendor-mismatch"); + }); + // ── ROUND 19: /start's response is a UNION, and every 200 finalizes ──── + + /** POST /start with whatever body is given. */ + const startWith = (request: APIRequestContext, body: Record) => + request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify(body), + maxRedirects: 0, + }); + + /** + * Put real bytes at `id`'s path, the only way this spec can: /start never + * carries bytes and the storage mock's signed URL is not PUT-able from + * here, so the single-shot route stores them and the row is pointed at + * them. Exactly what the two cases above already do. + */ + async function seedObjectFor(request: APIRequestContext, id: string, tag: string) { + const seeded = await request.post(INTAKE_PATH, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: intakeBody({ sourceRef: `${REF_PREFIX}${tag}-src` }), + maxRedirects: 0, + }); + expect(seeded.status()).toBe(200); + const row = await prisma.receiptIntake.findUnique({ where: { id: (await seeded.json()).id } }); + minted.push(row!.id); + await prisma.receiptIntake.update({ where: { id }, data: { storagePath: row!.storagePath } }); + return row!.storagePath; + } + + const finalizeWith = (request: APIRequestContext, id: string, uploadLease: string) => + request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ uploadLease }), + maxRedirects: 0, + }); + + test("CONCURRENT /start: every 200 carries a lease that FINALIZES", async ({ request }) => { + // THE ROUND-19 FINDING. reuseLiveLease used to mint a fresh + // uploadLeaseNonce on every adoption and leave it out of its own CAS, + // so two concurrent retries both wrote and both answered 200 with + // their own generation. Only the last write survived, and /finalize + // refuses any other generation -- so the earlier caller was handed a + // working signed URL and a lease that was already dead. + // + // An extension is not a new lease: same path, same version, so it now + // KEEPS the generation it adopted and both retries hand back the same + // one. + const ref = `${REF_PREFIX}twostep-lease-race`; + const body = { source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }; + + const [a, b] = await Promise.all([startWith(request, body), startWith(request, body)]); + + // A loser may legitimately answer the retryable 409 rather than a 200 + // -- the property is about what a 200 PROMISES, not about how many + // there are. Anything else is a failure. + const answers = await Promise.all([a, b].map(async r => ({ status: r.status(), json: await r.json() }))); + for (const { status } of answers) expect([200, 409]).toContain(status); + const ok = answers.filter(x => x.status === 200); + expect(ok.length, "at least one retry must succeed").toBeGreaterThan(0); + + const ids = new Set(ok.map(x => x.json.id)); + expect(ids.size).toBe(1, "one sourceRef, one row"); + const id = ok[0].json.id as string; + minted.push(id); + + for (const { json } of ok) { + expect(json.kind).toBe("upload", "an upload response says so"); + expect(json.uploadUrl).toBeTruthy(); + expect(json.uploadLease).toBeTruthy(); + } + const leases = new Set(ok.map(x => x.json.uploadLease)); + expect(leases.size).toBe(1, "one live lease, one generation"); + + // ...and it is not merely equal to itself: it is the one the row holds. + const row = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(row?.uploadLeaseNonce).toBe(ok[0].json.uploadLease); + + // THE PROPERTY, end to end: the bytes land, and EVERY 200's lease is + // accepted by /finalize. `lease-stale` here is the bug. + await seedObjectFor(request, id, "twostep-lease-race"); + for (const { json } of ok) { + const res = await finalizeWith(request, id, json.uploadLease); + const payload = await res.json(); + expect(payload.error).not.toBe("lease-stale"); + expect([200, 202]).toContain(res.status()); + } + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.state).toBe("RECEIVED"); + }); + + test("CONTROL: a lease /start never issued IS refused as stale", async ({ request }) => { + // Without this, a /finalize that accepted anything would pass the test + // above while the gate did nothing at all. + const ref = `${REF_PREFIX}twostep-lease-control`; + const started = await startWith( + request, + { source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }, + ); + expect(started.status()).toBe(200); + const { id, uploadLease } = await started.json(); + minted.push(id); + await seedObjectFor(request, id, "twostep-lease-control"); + + const stale = await finalizeWith(request, id, "not-a-lease-this-row-ever-had"); + expect(stale.status()).toBe(409); + const body = await stale.json(); + expect(body.error).toBe("lease-stale"); + expect(body.retryable).toBe(false, "calling again with the same body cannot help"); + + // And the REAL one still works, so the refusal above was about the + // lease and not about the row. + const good = await finalizeWith(request, id, uploadLease); + expect([200, 202]).toContain(good.status()); + }); + + test("the SETTLED union member carries no URL and no lease, and says so", async ({ request }) => { + // The spec used to claim every /start success hands back an upload URL. + // This branch never did: the document is already held and verified, so + // there is nothing to upload. A client that assumed the URL was always + // there read undefined with no way to tell that from a broken response. + const ref = `${REF_PREFIX}twostep-settled`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + const id = created.body.id as string; + + const again = await startWith( + request, + { source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }, + ); + expect(again.status()).toBe(200); + const body = await again.json(); + + expect(body.kind).toBe("settled"); + expect(body.alreadyReceived).toBe(true); + expect(body.id).toBe(id); + expect(body.state).toBeTruthy(); + expect(body.uploadUrl, "there is nothing to upload").toBeUndefined(); + expect(body.uploadLease, "and so no lease to echo").toBeUndefined(); + }); + + test("the UPLOAD union member carries the whole contract", async ({ request }) => { + const ref = `${REF_PREFIX}twostep-upload-shape`; + const res = await startWith( + request, + { source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64) }, + ); + expect(res.status()).toBe(200); + const body = await res.json(); + minted.push(body.id); + expect(body.kind).toBe("upload"); + for (const field of ["id", "uploadUrl", "token", "storagePath", "uploadLease", "maxBytes"]) { + expect(body[field], `an upload response carries ${field}`).toBeTruthy(); + } + expect(body.alreadyReceived).toBeUndefined(); + }); + + test("sha256 is REQUIRED on /start, and its absence is a deterministic 400", async ({ request }) => { + // It is the only thing that gives the row an identity before any bytes + // exist. The spec omitted it from the request shape entirely, so a + // mobile client written to that document would 400 on every call. + for (const [label, sha256] of [ + ["absent", undefined], + ["empty", ""], + ["too short", "abc"], + ["not hex", "z".repeat(64)], + ["uppercase-only garbage", "NOTAHASH".repeat(8)], + ] as const) { + const res = await startWith(request, { + source: "drive", + sourceRef: `${REF_PREFIX}twostep-nosha-${label.replace(/ /g, "-")}`, + mimeType: "image/png", + ...(sha256 === undefined ? {} : { sha256 }), + }); + expect(res.status()).toBe(400, label); + expect((await res.json()).reason).toBe("missing-sha256", label); + } + // Nothing was created for any of them. + const rows = await prisma.receiptIntake.findMany({ + where: { sourceRef: { startsWith: `${REF_PREFIX}twostep-nosha-` } }, + }); + expect(rows).toHaveLength(0); + }); +}); + +test.describe("round-9 intake contracts", () => { + const startPath = `${INTAKE_PATH}/start`; + const sha = (b64: string) => createHash("sha256").update(Buffer.from(b64, "base64")).digest("hex"); + const start = (request: APIRequestContext, body: Record) => + request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify(body), + maxRedirects: 0, + }); + + test("/start REFUSES without a sha256 — it is the row's only identity", async ({ request }) => { + // Without it a reused sourceRef is indistinguishable from an honest + // retry, and /start would hand out an upsert URL aimed at another + // document's object. + const res = await start(request, { + source: "drive", sourceRef: `${REF_PREFIX}nosha`, mimeType: "image/png", + }); + expect(res.status()).toBe(400); + expect((await res.json()).reason).toBe("missing-sha256"); + + const malformed = await start(request, { + source: "drive", sourceRef: `${REF_PREFIX}badsha`, mimeType: "image/png", sha256: "nope", + }); + expect(malformed.status()).toBe(400); + }); + + test("/start will not reissue an upsert URL without proving identity", async ({ request }) => { + const ref = `${REF_PREFIX}reissue`; + const first = await start(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + expect(first.status()).toBe(200); + minted.push((await first.json()).id); + + // Same hash: proven the same document, so the URL is reissued. + const proven = await start(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + expect(proven.status()).toBe(200); + expect((await proven.json()).uploadUrl).toBeTruthy(); + + // Different hash: refused BEFORE a URL exists, so receipt B can never be + // written over receipt A's object. + const unproven = await start(request, { + source: "drive", sourceRef: ref, mimeType: "image/png", sha256: sha(OTHER_PNG_BASE64), + }); + expect(unproven.status()).toBe(409); + expect((await unproven.json()).error).toBe("sourceRef-conflict"); + }); + + test("the PRODUCTION sourceRef formats are accepted by both endpoints", async ({ request }) => { + // Exactly what the Apps Script forwarder sends. A validator that + // accepted more than production sends would have accepted the bug it + // exists to stop; one that accepts LESS breaks the forwarder silently, + // so both shapes are driven through both doors. + const stamp = Date.now(); + const emailRef = `email:1993f0a3c9c4d0${stamp % 100}:0f1e2d3c4b5a6978`; + const chatRef = `chat:spaces/AAQANF47osY/messages/e2e.${stamp}:0`; + + const inline = await postIntake(request, JSON.stringify({ + source: "email", sourceRef: emailRef, + fileBase64: PNG_BASE64, mimeType: "image/png", fileName: "e.png", + })); + expect(inline.res.status()).toBe(200); + minted.push(inline.body.id); + + const started = await request.post(`${INTAKE_PATH}/start`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "chat", sourceRef: chatRef, mimeType: "image/png", + sha256: createHash("sha256").update(Buffer.from(PNG_BASE64, "base64")).digest("hex"), + }), + maxRedirects: 0, + }); + expect(started.status()).toBe(200); + minted.push((await started.json()).id); + }); + + test("a namespace with no id, and an oversized one, are refused at both doors", async ({ request }) => { + // `drive:` with an empty tail was a valid, unique, PERMANENT idempotency + // key: every later empty-tail forward collided with it and was told + // "already received", so real receipts were dropped. + const bad: Array<[string, string]> = [ + ["drive:", "invalid-sourceRef"], + [`drive:${"a".repeat(600)}`, "sourceRef-too-long"], + ["drive:short", "invalid-sourceRef"], + ]; + for (const [sourceRef, reason] of bad) { + const inline = await postIntake(request, intakeBody({ sourceRef })); + expect(inline.res.status(), sourceRef).toBe(400); + expect(inline.body.reason, sourceRef).toBe(reason); + + const started = await request.post(`${INTAKE_PATH}/start`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "drive", sourceRef, mimeType: "image/png", sha256: "a".repeat(64), + }), + maxRedirects: 0, + }); + expect(started.status(), sourceRef).toBe(400); + expect((await started.json()).reason, sourceRef).toBe(reason); + } + // And nothing was created for any of them. + const rows = await prisma.receiptIntake.findMany({ where: { sourceRef: { startsWith: "drive:a" } } }); + expect(rows).toHaveLength(0); + }); + + test("every sourceRef rule /start enforces, the inline endpoint enforces IDENTICALLY", async ({ request }) => { + // The inline endpoint used to check only the NAMESPACE PREFIX while + // /start ran decideSource(), so a secret caller could push a shape + // /start refuses through the other door: junk QuickBooks identities + // (a `drive` row books under its tail) and oversized values headed for + // a UNIQUE index. + // + // Prefix-only validation accepts every row below, which is why "both + // return 400" is not the assertion. The doors must agree on the REASON + // too — a forwarder that can tell them apart will learn to prefer the + // lenient one, and that is how the two implementations drifted in the + // first place. + const refused: Array<[string, string, string]> = [ + // Right namespace, wrong SHAPE — precisely what a prefix check misses. + // One email message can carry several receipts, so the message id + // alone is not an identity. + ["email", "email:1993f0a3c9c4d0d2", "invalid-sourceRef"], + ["email", "email:1993f0a3c9c4d0d2:NOTHEX0123456789", "invalid-sourceRef"], + // A Chat ref without its attachment index, and one whose resource + // name is not a resource name. + ["chat", "chat:spaces/AAQANF47osY/messages/abc.def", "invalid-sourceRef"], + ["chat", "chat:not-a-resource-name:0", "invalid-sourceRef"], + // Control characters and whitespace: this value is echoed into logs + // and compared for equality. A LEADING one is deliberately not a + // case here — both doors trim before validating, and that agreement + // is itself part of the parity. + ["drive", "drive:1AbCdEfGh IjKlMnOp", "invalid-sourceRef"], + ["drive", "drive:1AbCdEfGh\u0001IjKlMnOp", "invalid-sourceRef"], + // Oversized, in a namespace the drive-only case above does not reach. + ["email", `email:${"a".repeat(600)}:0f1e2d3c4b5a6978`, "sourceRef-too-long"], + // A well-formed ref, in a namespace the caller did not declare. + ["drive", "email:1993f0a3c9c4d0d2:0f1e2d3c4b5a6978", "sourceRef-namespace-mismatch"], + ]; + + for (const [source, sourceRef, reason] of refused) { + const label = `${source} / ${JSON.stringify(sourceRef).slice(0, 64)}`; + const inline = await postIntake(request, intakeBody({ source, sourceRef })); + const started = await start(request, { + source, sourceRef, mimeType: "image/png", sha256: sha(PNG_BASE64), + }); + expect(inline.res.status(), `inline ${label}`).toBe(400); + expect(started.status(), `start ${label}`).toBe(400); + expect(inline.body.reason, `inline ${label}`).toBe(reason); + expect((await started.json()).reason, `start ${label}`).toBe(reason); + } + + // Neither door created a row for any of them. A 400 that still inserts + // is the oversized-unique-index half of the finding. + const rows = await prisma.receiptIntake.findMany({ + where: { sourceRef: { in: refused.map(([, sourceRef]) => sourceRef) } }, + select: { sourceRef: true }, + }); + expect(rows).toHaveLength(0); + }); + + test("a settled row whose object is GONE is 409 file-missing, never a cheerful 200", async ({ request }) => { + // Both replay paths used to answer success from the row alone. The + // forwarders treat that as permission to delete their only copy, so a + // row whose object had vanished got a 200 and the receipt ceased to + // exist anywhere. + const ref = `${REF_PREFIX}settled-gone`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + minted.push(id); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.state).toBe("RECEIVED"); + + // Point the settled row at a path nothing was ever written to. + await prisma.receiptIntake.update({ + where: { id }, + data: { storagePath: `receipts/intake/${id}.vanished.png` }, + }); + + // /finalize: the alreadyFinalized path. + const finalized = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(finalized.status()).toBe(409); + const finalBody = await finalized.json(); + expect(finalBody.error).toBe("file-missing"); + expect(finalBody.retryable).toBe(true); + + // /start: the alreadyReceived path, same sourceRef. + const started = await request.post(`${INTAKE_PATH}/start`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "drive", sourceRef: ref, mimeType: "image/png", + sha256: createHash("sha256").update(Buffer.from(PNG_BASE64, "base64")).digest("hex"), + }), + maxRedirects: 0, + }); + expect(started.status()).toBe(409); + const startBody = await started.json(); + expect(startBody.error).toBe("file-missing"); + expect(startBody.retryable).toBe(true); + expect(startBody.uploadUrl).toBeUndefined(); + + // The row is untouched by either refusal. + const after = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(after?.state).toBe("RECEIVED"); + }); + + test("a settled row that DOES still have its object replays as success", async ({ request }) => { + // The control: without it both refusals above would pass against an + // endpoint that had simply stopped answering 200 at all. + const ref = `${REF_PREFIX}settled-present`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + const finalized = await request.post(`${INTAKE_PATH}/${created.body.id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ uploadLease: await leaseOf(created.body.id) }), + maxRedirects: 0, + }); + expect(finalized.status()).toBe(200); + expect((await finalized.json()).alreadyFinalized).toBe(true); + }); + + test("a text receipt is refused with a 415 that says what to send instead", async ({ request }) => { + // QuickBooks cannot attach a .txt, so accepting one meant reading it and + // then stranding it unbookable mid-pipeline. + const res = await postIntake(request, intakeBody({ + sourceRef: `${REF_PREFIX}textfile`, + fileBase64: Buffer.from("VENDOR: Lowes\nTOTAL: 10.00").toString("base64"), + mimeType: "text/plain", + })); + expect(res.res.status()).toBe(415); + expect(res.body.error).toBe("unsupported-file-type"); + expect(res.body.reason).toMatch(/PDF/i); + expect(res.body.accepted).toContain("application/pdf"); + }); + + test("the JSON inline limit is 3 MiB raw and says so", async ({ request }) => { + // base64 inflates by 4/3, so 4 MiB raw is a ~5.4 MiB request — over the + // platform body cap, which used to reject it before this code ran. + const big = Buffer.alloc(3 * 1024 * 1024 + 1, 7).toString("base64"); + const res = await postIntake(request, intakeBody({ + sourceRef: `${REF_PREFIX}toobig`, fileBase64: big, + })); + expect(res.res.status()).toBe(413); + expect(res.body.error).toBe("payload-too-large"); + expect(res.body.maxInlineBytes).toBe(3 * 1024 * 1024); + expect(res.body.use).toMatch(/intake\/start/); + }); + + test("a sequential finalize retry still applies late fields", async ({ request }) => { + // The row already reached RECEIVED, so this takes the alreadyFinalized + // path — and answering it without applying the job assignment would drop + // that assignment while telling the caller it worked. + const ref = `${REF_PREFIX}latefields`; + // The row carries the JOB, because a phase is only valid against one: + // `e2e-mob-cc-demo` is a phase of PROJECT_ID via the approved mobile + // estimate (data.setup.ts). Without the project this is a 400 + // cost-code-without-project, which is a different test (below). + const created = await postIntake(request, intakeBody({ sourceRef: ref, projectId: PROJECT_ID })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + minted.push(id); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.state).toBe("RECEIVED"); + + const applied = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ ...{ costCodeId: "e2e-mob-cc-demo" }, uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(applied.status()).toBe(200); + expect((await applied.json()).alreadyFinalized).toBe(true); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.costCodeId).toBe("e2e-mob-cc-demo"); + + // Re-sending the SAME value is idempotent. + const same = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ ...{ costCodeId: "e2e-mob-cc-demo" }, uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(same.status()).toBe(200); + + // A DIFFERENT value is a conflict, never a silent overwrite of what a + // human may already have set. + const conflicting = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ ...{ costCodeId: "e2e-mob-cc-dryw" }, uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(conflicting.status()).toBe(409); + expect((await conflicting.json()).error).toBe("late-fields-conflict"); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.costCodeId).toBe("e2e-mob-cc-demo"); + }); + + test("a published row's object lives at the sealed, content-addressed path", async ({ request }) => { + const ref = `${REF_PREFIX}sealed-path`; + const created = await postIntake(request, intakeBody({ sourceRef: ref })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + const row = await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }); + // The single-shot path publishes directly; the two-step path seals. Either + // way the row must never be left pointing somewhere a client holds a URL for. + expect(row?.fileSha256).toHaveLength(64); + }); +}); + +test.describe("round-10 finalize authorization and recovery", () => { + const startPath = `${INTAKE_PATH}/start`; + const finalize = async (request: APIRequestContext, id: string, body: Record) => + request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ ...body, uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + + test("truncated JSON in the finalize body is a 400, not silently treated as empty", async ({ request }) => { + // req.json() throws on BOTH a genuinely empty body and a malformed + // one; a bare try/catch collapsed truncated JSON into "no fields", + // which turned a request-level bug into a silent no-op instead of an + // error the caller could see and retry against. + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}truncjson` })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + // MUST be a Buffer, not a plain string. Playwright's APIRequestContext + // treats a STRING `data` sent under an `application/json` content-type + // specially: if the string is not itself valid JSON, it silently + // re-wraps it with `JSON.stringify()` — turning this truncated body + // into a well-formed JSON payload whose value is the truncated text as + // a STRING, not the object it looks like. The route's `JSON.parse` then + // succeeds (on a string, not an object), every field reads as + // `undefined`, and the request looks exactly like a genuinely empty + // body — silently defeating the very truncation this test exists to + // catch. A Buffer bypasses that re-wrap and puts these exact bytes on + // the wire. + const res = await request.post(`${INTAKE_PATH}/${created.body.id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: Buffer.from('{"costCodeId": "e2e-mob-cc-demo"', "utf8"), // truncated: missing closing brace + maxRedirects: 0, + }); + expect(res.status()).toBe(400); + expect((await res.json()).reason).toBe("invalid-json"); + + // Nothing was written: no late field applied and no state change. + const row = await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }); + expect(row?.state).toBe("RECEIVED"); + expect(row?.costCodeId).toBeNull(); + }); + + test("a JSON body that parses to a non-object is a 400, not silently treated as empty", async ({ request }) => { + // `"just a string"` is perfectly valid JSON — JSON.parse succeeds — but + // it is not an OBJECT, so body.sha256 / body.costCodeId / body.projectId + // all read as undefined off it, identical to a genuinely empty body. + // Without an explicit object check this fell through to 200 with + // nothing applied — the same silent-no-op shape as the truncated-JSON + // case above. A number, boolean, or bare array parses just as cleanly + // and would hit the same gap. + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}nonobjjson` })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + // Buffer for the same reason as the truncated-JSON case above: a raw + // STRING `data` under an application/json content-type gets re-wrapped + // by Playwright when it isn't already valid JSON. This payload IS + // already valid JSON (a quoted string), so Playwright would send it + // as-is either way — the Buffer just keeps this test explicit about + // what bytes actually go on the wire. + const res = await request.post(`${INTAKE_PATH}/${created.body.id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: Buffer.from('"just a string"', "utf8"), + maxRedirects: 0, + }); + expect(res.status()).toBe(400); + expect((await res.json()).reason).toBe("invalid-json"); + + // Nothing was written: no late field applied and no state change. + const row = await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }); + expect(row?.state).toBe("RECEIVED"); + expect(row?.costCodeId).toBeNull(); + }); + + test("a session caller cannot attach a project it may not reach", async ({ playwright, request }) => { + // Without this any authenticated user could file a receipt against any + // project by id. + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}authz` })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + const employee = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: "e2e/.auth/contract-user.json", + }); + const res = await employee.post(`${INTAKE_PATH}/${created.body.id}/finalize`, { + headers: { "content-type": "application/json" }, + data: JSON.stringify({ ...{ projectId: "e2e-scope-oos-project" }, uploadLease: await leaseOf(created.body.id) }), + maxRedirects: 0, + }); + // Either refused outright (403) or invisible to this caller (404) — what + // must NOT happen is the project landing on the row. + expect([403, 404]).toContain(res.status()); + expect((await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }))?.projectId) + .not.toBe("e2e-scope-oos-project"); + await employee.dispose(); + }); + + test("a cost code that is not a phase of the job is refused", async ({ request }) => { + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}phasecheck` })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + const res = await finalize(request, created.body.id, { costCodeId: "e2e-mob-cc-demo" }); + // No project on the row and none supplied, so a phase is meaningless. + expect(res.status()).toBe(400); + expect((await res.json()).error).toBe("cost-code-without-project"); + }); + + test("the ingest secret cannot finalize a row it does not own the SOURCE of", async ({ request }) => { + // decideSource already stops a forwarder CREATING a row outside its + // namespace, but finalize took `via === "secret"` as blanket authority + // over any id — so the Apps Script key could publish, re-point and + // attach a job to somebody's mobile capture or web upload. + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}notmysource` })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + minted.push(id); + + // Exactly the shape a phone or the web uploader leaves behind. Seeded + // directly because the secret can no longer create one. + await prisma.receiptIntake.update({ + where: { id }, + data: { source: "mobile", sourceRef: `mobile:${id}` }, + }); + + const res = await request.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ ...{ projectId: PROJECT_ID }, uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(res.status()).toBe(403); + const body = await res.json(); + expect(body.error).toBe("source-not-owned"); + // 403 before ANY detail is returned or written. + expect(body.storagePath).toBeUndefined(); + expect(body.state).toBeUndefined(); + const after = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(after?.projectId).toBeNull(); + }); + + test("a user with no access to the row's OWN job cannot finalize it", async ({ playwright, request }) => { + // Revocation has to bite on the project the ROW holds, not only on one + // the request supplies. Before this, a user whose access was revoked + // could still publish their existing row on that job — and attach a + // phase to it — simply by not mentioning the project. + const created = await postIntake(request, intakeBody({ + sourceRef: `${REF_PREFIX}revoked`, projectId: "e2e-scope-oos-project", + })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + minted.push(id); + + // The row is THEIRS (so the ownership check passes and this test is + // about project access, not about a guessed id), on a job + // contract-staff has no ProjectAccess for. + const owner = await prisma.user.findUnique({ where: { email: "contract-staff@test.local" } }); + expect(owner, "contract-staff fixture must exist").toBeTruthy(); + await prisma.receiptIntake.update({ where: { id }, data: { createdById: owner!.id } }); + + const employee = await playwright.request.newContext({ + baseURL: "http://localhost:3000", + storageState: "e2e/.auth/contract-user.json", + }); + const res = await employee.post(`${INTAKE_PATH}/${id}/finalize`, { + headers: { "content-type": "application/json" }, + // No projectId in the body: the row already has one, and that is + // the whole point of the case. + data: JSON.stringify({ ...{ costCodeId: "e2e-mob-cc-demo" }, uploadLease: await leaseOf(id) }), + maxRedirects: 0, + }); + expect(res.status()).toBe(403); + expect((await res.json()).error).toBe("project-forbidden"); + // NOTHING written: the gate runs before any late field is applied. + const after = await prisma.receiptIntake.findUnique({ where: { id } }); + expect(after?.costCodeId).toBeNull(); + expect(after?.projectId).toBe("e2e-scope-oos-project"); + await employee.dispose(); + }); + + test("a cost code that is not a phase of THIS job is refused", async ({ request }) => { + // The row has a job, and the phase is not one of its phases. Neither + // half is malformed — the PAIR is wrong, and letting it through files + // the receipt against a line this job never budgeted. + const created = await postIntake(request, intakeBody({ + sourceRef: `${REF_PREFIX}phasejob`, projectId: PROJECT_ID, + })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + const res = await finalize(request, created.body.id, { costCodeId: "e2e-not-a-phase-of-anything" }); + expect(res.status()).toBe(400); + expect((await res.json()).error).toBe("cost-code-not-a-phase"); + expect((await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }))?.costCodeId) + .toBeNull(); + }); + + test("the job's OWN phase is accepted", async ({ request }) => { + // The control: without it the two refusals above would pass just as + // well against a gate that refused everything. + const created = await postIntake(request, intakeBody({ + sourceRef: `${REF_PREFIX}phaseok`, projectId: PROJECT_ID, + })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + const res = await finalize(request, created.body.id, { costCodeId: "e2e-mob-cc-demo" }); + expect(res.status()).toBe(200); + expect((await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }))?.costCodeId) + .toBe("e2e-mob-cc-demo"); + }); + + test("late fields are refused once the row has been routed", async ({ request }) => { + // Past RECEIVED the dedup keys, the phase suggestion and possibly a + // booking were all derived from the project the row had at the time. + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}toolate` })); + expect(created.res.status()).toBe(200); + const id = created.body.id; + minted.push(id); + + await prisma.receiptIntake.update({ where: { id }, data: { state: "BOOKED" } }); + const res = await finalize(request, id, { projectId: "e2e-scope-oos-project" }); + expect(res.status()).toBe(409); + expect((await res.json()).error).toBe("late-fields-too-late"); + expect((await prisma.receiptIntake.findUnique({ where: { id } }))?.projectId).toBeNull(); + }); + + test("finalize returns the PERSISTED values, not what the caller asked for", async ({ request }) => { + const created = await postIntake(request, intakeBody({ sourceRef: `${REF_PREFIX}persisted` })); + expect(created.res.status()).toBe(200); + minted.push(created.body.id); + + const res = await finalize(request, created.body.id, {}); + expect(res.status()).toBe(200); + const body = await res.json(); + const row = await prisma.receiptIntake.findUnique({ where: { id: created.body.id } }); + expect(body.state).toBe(row?.state); + expect(body.fileSha256).toBe(row?.fileSha256); + expect(body.costCodeId).toBe(row?.costCodeId ?? null); + }); + + test("/start refuses an unsupported type with 415 and creates NO row", async ({ request }) => { + const ref = `${REF_PREFIX}start-415`; + const res = await request.post(startPath, { + headers: { "content-type": "application/json", "x-receipt-intake-secret": SECRET }, + data: JSON.stringify({ + source: "drive", sourceRef: ref, mimeType: "text/plain", sha256: "a".repeat(64), + }), + maxRedirects: 0, + }); + expect(res.status()).toBe(415); + const body = await res.json(); + expect(body.error).toBe("unsupported-file-type"); + expect(body.accepted).not.toContain("text/plain"); + // The row must not exist — a STAGING row for a document we will never + // accept is something the sweeper then has to reason about. + expect(await prisma.receiptIntake.findUnique({ where: { sourceRef: ref } })).toBeNull(); + }); +}); diff --git a/package.json b/package.json index 42a78bff4..8be92ffd2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "test:pipeline-digest": "tsx --test tests/pipeline-digest-route.test.ts", "test:qbo-token-refresh": "tsx --test tests/qbo-token-refresh-timeout.test.ts", "test:proxy-health": "tsx --test tests/proxy-health-pipeline.test.ts", - "test:qbo-payments-outage": "tsx --test tests/qbo-payments-outage.test.ts", + "test:qbo-payments-outage": "tsx --test tests/qbo-payments-outage.test.ts tests/qbo-payments-cursor.test.ts", + "test:cron-lease": "tsx --test tests/cron-lease.test.ts", + "test:receipt-intake": "tsx --test tests/cron-lease.test.ts tests/receipt-intake-keys.test.ts tests/receipt-intake-route-state.test.ts tests/receipt-intake-upload-lease.test.ts tests/receipt-intake-read.test.ts tests/receipt-intake-book.test.ts tests/receipt-intake-worker.test.ts tests/receipt-intake-auth.test.ts tests/apply-receipt-intake.test.ts tests/receipt-intake-archive-contract.test.ts tests/receipt-intake-cutover.test.ts tests/secure-storage-classification.test.ts tests/receipt-intake-raw-sql.test.ts tests/receipt-intake-stored-object.test.ts tests/receipt-intake-phases.test.ts tests/receipt-intake-cleanup.test.ts tests/receipt-intake-late-fields.test.ts tests/receipt-intake-reject.test.ts tests/receipt-intake-claim-release.test.ts tests/receipt-intake-lease-fence.test.ts tests/receipt-url.test.ts tests/supabase-storage-mock.test.ts", "test:bank-ledger": "tsx --test tests/bank-ledger.test.ts tests/bank-ledger-ingest-route.test.ts tests/bank-ledger-reconcile-route.test.ts tests/bank-ledger-status-route.test.ts tests/apply-bank-ledger.test.ts tests/migration-history-blind-spots.test.ts tests/parse-wtb-statement.test.ts tests/parse-wtb-daily-csv.test.ts tests/post-qbo-register.test.ts tests/receipt-match.test.ts tests/vendor-alias.test.ts tests/receipt-policy.test.ts tests/bank-image.test.ts tests/post-bank-images.test.ts tests/extract-check-payers.test.ts tests/check-payer-match.test.ts tests/check-evidence.test.ts tests/deposit-attribution.test.ts tests/deposit-review.test.ts", "test:automation-key-resolver": "tsx --test tests/automation-key-resolver.test.ts", "test:automation-register": "tsx --test tests/automation-register-filters.test.ts tests/match-receipt-journey.test.ts tests/automation-events-grouping.test.ts tests/automation-format.test.ts", @@ -27,7 +29,7 @@ "test:budget-math": "tsx --test tests/budget-math.test.ts", "test:estimate-item-upsert": "tsx --test tests/estimate-item-upsert.test.ts", "test:crew-auto-assign": "tsx --test tests/crew-auto-assign.test.ts tests/backfill-crew-assignments.test.ts", - "test:unit": "tsx --test tests/proxy-mobile-routes.test.ts tests/estimate-item-payload.test.ts tests/budget-math.test.ts tests/payment-date-display.test.ts tests/estimate-item-upsert.test.ts tests/geocode-zip-guard.test.ts tests/takeoff-tax-split.test.ts tests/takeoff-convert-tax.test.ts tests/takeoff-tax-prompt.test.ts tests/overtime.test.ts tests/wa-breaks.test.ts tests/logistics-formalize.test.ts tests/pay-period-summary-route.test.ts tests/tz-date.test.ts tests/phase-options.test.ts tests/mobile-phases-route.test.ts tests/manager-crew-route.test.ts tests/logistics-time-entry.test.ts tests/project-phases.test.ts tests/phase-items.test.ts tests/cost-coding.test.ts tests/job-variance.test.ts tests/job-variance-db.test.ts tests/polish-notes-route.test.ts tests/time-entries-clockout-route.test.ts tests/crew-auto-assign.test.ts tests/backfill-crew-assignments.test.ts tests/commercial-siding-qbo-evidence.test.ts tests/inspection-core.test.ts tests/portal-inspections.test.ts tests/percent-complete.test.ts tests/project-financials-earned.test.ts tests/percent-complete-cron.test.ts tests/percent-complete-recalc-race.test.ts tests/percent-complete-co-tasks.test.ts tests/percent-complete-backfill.test.ts tests/percent-complete-digest-guard.test.ts tests/apply-scripts-inert-on-import.test.ts tests/blind-spots-cr-guard.test.ts tests/schedule-task-dates.test.ts tests/deposit-sweep.test.ts tests/schedule-dates.test.ts tests/schedule-task-result.test.ts tests/users-route-pin-leak.test.ts tests/isomorphic-dompurify-cjs-chain.test.ts tests/gusto-export.test.ts tests/payroll-schema-drift-db.test.ts tests/time-entry-reassign-db.test.ts tests/payroll-apply-idempotent-db.test.ts tests/payroll-rate-lock-db.test.ts tests/payroll-paytype-lock-db.test.ts tests/change-order-tag-race-db.test.ts tests/zero-rate-create-db.test.ts tests/payroll-apply-script-parity.test.ts tests/payroll-parent-delete.test.ts tests/payroll-round16.test.ts tests/payroll-period-lock.test.ts tests/zero-rate-clockout.test.ts tests/rate-import.test.ts tests/help-chat-bug-widget.test.ts tests/gusto-export-route.test.ts tests/pay-rate-write.test.ts tests/help-chat-submission-guard.test.ts tests/help-chat-request-route.test.ts tests/payroll-writer-manifest.test.ts tests/gusto-access.test.ts tests/manual-time-entry-auth.test.ts tests/time-expense-core-guards.test.ts tests/gusto-export-consistency.test.ts tests/integration-store-writes.test.ts tests/users-create-payroll-body.test.ts tests/payroll-settings-lock-db.test.ts tests/payroll-user-writer-manifest.test.ts tests/payroll-activation-lock-db.test.ts tests/payroll-locked-snapshot-db.test.ts tests/payroll-settlement-zone-db.test.ts tests/payroll-staff-only-db.test.ts tests/time-entries-projection-db.test.ts tests/user-mutation-escalation.test.ts tests/user-mutation-target-race-db.test.ts tests/time-entry-response-tripwire.test.ts tests/integration-access.test.ts tests/help-chat-public-projection.test.ts tests/payroll-label-lock-db.test.ts tests/gusto-mapping-route.test.ts tests/payroll-lock-order-db.test.ts tests/payroll-apply-target.test.ts tests/user-mutation-actor-race-db.test.ts tests/payroll-actor-reauth-db.test.ts tests/payroll-actor-reauth-manifest.test.ts tests/financial-access.test.ts tests/payroll-page-access.test.ts tests/qb-timed-fetch.test.ts tests/pipeline-health.test.ts tests/cron-auth.test.ts tests/pipeline-digest-route.test.ts tests/qbo-token-refresh-timeout.test.ts tests/proxy-health-pipeline.test.ts tests/qbo-payments-outage.test.ts tests/progress-billing-stage.test.ts tests/qbo-ambiguous-create.test.ts tests/qbo-document-create-boundary.test.ts tests/qbo-paylink-repair-cas.test.ts tests/qbo-parked-row-guards.test.ts tests/billing-qbo-deadline.test.ts tests/qbo-maintenance-sweep.test.ts tests/qbo-receipt-push.test.ts tests/qbo-expense-sync.test.ts tests/qbo-expense-sync-route.test.ts tests/qbo-sync-route-auth.test.ts tests/qbo-send-gate.test.ts tests/qbo-maintenance-cron.test.ts tests/qbo-document-link-writes.test.ts tests/qbo-resolve-reason-required.test.ts tests/qbo-marker-grammar.test.ts tests/server-action-gates.test.ts tests/qbo-compensation-claim.test.ts", + "test:unit": "tsx --test tests/proxy-mobile-routes.test.ts tests/estimate-item-payload.test.ts tests/budget-math.test.ts tests/payment-date-display.test.ts tests/estimate-item-upsert.test.ts tests/geocode-zip-guard.test.ts tests/takeoff-tax-split.test.ts tests/takeoff-convert-tax.test.ts tests/takeoff-tax-prompt.test.ts tests/overtime.test.ts tests/wa-breaks.test.ts tests/logistics-formalize.test.ts tests/pay-period-summary-route.test.ts tests/tz-date.test.ts tests/phase-options.test.ts tests/mobile-phases-route.test.ts tests/manager-crew-route.test.ts tests/logistics-time-entry.test.ts tests/project-phases.test.ts tests/phase-items.test.ts tests/cost-coding.test.ts tests/job-variance.test.ts tests/job-variance-db.test.ts tests/polish-notes-route.test.ts tests/time-entries-clockout-route.test.ts tests/crew-auto-assign.test.ts tests/backfill-crew-assignments.test.ts tests/commercial-siding-qbo-evidence.test.ts tests/inspection-core.test.ts tests/portal-inspections.test.ts tests/percent-complete.test.ts tests/project-financials-earned.test.ts tests/percent-complete-cron.test.ts tests/percent-complete-recalc-race.test.ts tests/percent-complete-co-tasks.test.ts tests/percent-complete-backfill.test.ts tests/percent-complete-digest-guard.test.ts tests/apply-scripts-inert-on-import.test.ts tests/blind-spots-cr-guard.test.ts tests/schedule-task-dates.test.ts tests/deposit-sweep.test.ts tests/schedule-dates.test.ts tests/schedule-task-result.test.ts tests/users-route-pin-leak.test.ts tests/isomorphic-dompurify-cjs-chain.test.ts tests/gusto-export.test.ts tests/payroll-schema-drift-db.test.ts tests/time-entry-reassign-db.test.ts tests/payroll-apply-idempotent-db.test.ts tests/payroll-rate-lock-db.test.ts tests/payroll-paytype-lock-db.test.ts tests/change-order-tag-race-db.test.ts tests/zero-rate-create-db.test.ts tests/payroll-apply-script-parity.test.ts tests/payroll-parent-delete.test.ts tests/payroll-round16.test.ts tests/payroll-period-lock.test.ts tests/zero-rate-clockout.test.ts tests/rate-import.test.ts tests/help-chat-bug-widget.test.ts tests/gusto-export-route.test.ts tests/pay-rate-write.test.ts tests/help-chat-submission-guard.test.ts tests/help-chat-request-route.test.ts tests/payroll-writer-manifest.test.ts tests/gusto-access.test.ts tests/manual-time-entry-auth.test.ts tests/time-expense-core-guards.test.ts tests/gusto-export-consistency.test.ts tests/integration-store-writes.test.ts tests/users-create-payroll-body.test.ts tests/payroll-settings-lock-db.test.ts tests/payroll-user-writer-manifest.test.ts tests/payroll-activation-lock-db.test.ts tests/payroll-locked-snapshot-db.test.ts tests/payroll-settlement-zone-db.test.ts tests/payroll-staff-only-db.test.ts tests/time-entries-projection-db.test.ts tests/user-mutation-escalation.test.ts tests/user-mutation-target-race-db.test.ts tests/time-entry-response-tripwire.test.ts tests/integration-access.test.ts tests/help-chat-public-projection.test.ts tests/payroll-label-lock-db.test.ts tests/gusto-mapping-route.test.ts tests/payroll-lock-order-db.test.ts tests/payroll-apply-target.test.ts tests/user-mutation-actor-race-db.test.ts tests/payroll-actor-reauth-db.test.ts tests/payroll-actor-reauth-manifest.test.ts tests/financial-access.test.ts tests/payroll-page-access.test.ts tests/qb-timed-fetch.test.ts tests/pipeline-health.test.ts tests/cron-auth.test.ts tests/pipeline-digest-route.test.ts tests/qbo-token-refresh-timeout.test.ts tests/proxy-health-pipeline.test.ts tests/qbo-payments-outage.test.ts tests/progress-billing-stage.test.ts tests/qbo-ambiguous-create.test.ts tests/qbo-document-create-boundary.test.ts tests/qbo-paylink-repair-cas.test.ts tests/qbo-parked-row-guards.test.ts tests/billing-qbo-deadline.test.ts tests/qbo-maintenance-sweep.test.ts tests/qbo-receipt-push.test.ts tests/qbo-expense-sync.test.ts tests/qbo-expense-sync-route.test.ts tests/qbo-sync-route-auth.test.ts tests/qbo-send-gate.test.ts tests/qbo-maintenance-cron.test.ts tests/qbo-document-link-writes.test.ts tests/qbo-resolve-reason-required.test.ts tests/qbo-marker-grammar.test.ts tests/server-action-gates.test.ts tests/qbo-compensation-claim.test.ts tests/qbo-payments-cursor.test.ts tests/cron-lease.test.ts tests/receipt-intake-keys.test.ts tests/receipt-intake-route-state.test.ts tests/receipt-intake-upload-lease.test.ts tests/receipt-intake-read.test.ts tests/receipt-intake-book.test.ts tests/receipt-intake-worker.test.ts tests/receipt-intake-auth.test.ts tests/apply-receipt-intake.test.ts tests/receipt-intake-archive-contract.test.ts tests/receipt-intake-cutover.test.ts tests/secure-storage-classification.test.ts tests/receipt-intake-raw-sql.test.ts tests/receipt-intake-stored-object.test.ts tests/receipt-intake-phases.test.ts tests/receipt-intake-cleanup.test.ts tests/receipt-intake-late-fields.test.ts tests/receipt-intake-reject.test.ts tests/receipt-intake-claim-release.test.ts tests/receipt-intake-lease-fence.test.ts tests/receipt-url.test.ts tests/supabase-storage-mock.test.ts", "test:e2e": "playwright test", "test:mobile-e2e": "node e2e/mobile-app/run-mobile-e2e.mjs", "test:qa": "npx playwright test e2e/quality-gate.spec.ts", diff --git a/prisma/migrations/20260901000000_receipt_intake/migration.sql b/prisma/migrations/20260901000000_receipt_intake/migration.sql new file mode 100644 index 000000000..621b6ff02 --- /dev/null +++ b/prisma/migrations/20260901000000_receipt_intake/migration.sql @@ -0,0 +1,202 @@ +-- ReceiptIntake schema history (Receipt Pipeline v2, Phase 1 — +-- docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §2). The table is first applied to +-- production through the guarded rollout script scripts/apply-receipt-intake.mjs; +-- this migration carries the SAME statements so a fresh database built from +-- prisma/migrations/ reproduces production. Keep both additive and idempotent. +-- +-- Two objects here are invisible to Prisma and MUST stay hand-written: +-- * the CHECK on "state" (Prisma has no check-constraint concept), and +-- * the PARTIAL unique index on "dedupStrongKey" (Prisma's diff engine drops +-- partial indexes silently — CLAUDE.md, prisma/prisma-blind-spots.json). +-- The partial index is not an optimisation: it IS the strong-dedup claim. The +-- read step writes the keys and reads a unique violation as "someone already +-- owns this purchase", which is what replaces the Apps Script's Properties lock. + +CREATE TABLE IF NOT EXISTS "ReceiptIntake" ( + "id" TEXT NOT NULL, + "source" TEXT NOT NULL, + "sourceRef" TEXT NOT NULL, + "state" TEXT NOT NULL DEFAULT 'STAGING', + "dryRun" BOOLEAN NOT NULL DEFAULT true, + "stateReason" TEXT, + "taxWarning" TEXT, + "projectId" TEXT, + "costCodeId" TEXT, + "suggestedCostCodeId" TEXT, + "suggestedConfidence" DOUBLE PRECISION, + "createdById" TEXT, + "storagePath" TEXT NOT NULL, + "fileName" TEXT, + "mimeType" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "fileSha256" TEXT NOT NULL, + "expectedSha256" TEXT, + "uploadUrlExpiresAt" TIMESTAMP(3), + "uploadLeaseVersion" INTEGER NOT NULL DEFAULT 0, + "uploadLeaseNonce" TEXT, + "vendor" TEXT, + "txnDate" DATE, + "totalCents" INTEGER, + "taxCents" INTEGER, + "docType" TEXT, + "refNumber" TEXT, + "memo" TEXT, + "readJson" TEXT, + "readAt" TIMESTAMP(3), + "dedupStrongKey" TEXT, + "dedupWeakKey" TEXT, + "duplicateOfId" TEXT, + "sendAttempted" BOOLEAN NOT NULL DEFAULT false, + "archivedByV1" BOOLEAN NOT NULL DEFAULT false, + "qbPurchaseId" TEXT, + "expenseId" TEXT, + "archiveDriveFileId" TEXT, + "claimToken" TEXT, + "claimedAt" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "busyPasses" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextRetryAt" TIMESTAMP(3), + "bookedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReceiptIntake_pkey" PRIMARY KEY ("id") +); + +-- Additive upgrade for a table created by an earlier run of +-- scripts/apply-receipt-intake.mjs: CREATE TABLE IF NOT EXISTS is a no-op on an +-- existing table, so a column added to the CREATE above would never reach it. +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "busyPasses" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "expectedSha256" TEXT; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadUrlExpiresAt" TIMESTAMP(3); +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadLeaseVersion" INTEGER NOT NULL DEFAULT 0; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadLeaseNonce" TEXT; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "sendAttempted" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "archivedByV1" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "claimToken" TEXT; +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3); +-- The dropped-tax-reading marker's DURABLE home. It lived in stateReason, +-- which every deferred booking and every park overwrites, so the evidence +-- was gone by the time the row reached BOOKED. +ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "taxWarning" TEXT; + +-- THE STATE DEFAULT IS REPAIRED, not merely declared on a fresh table. +-- CREATE TABLE above carries DEFAULT 'STAGING'; a table an earlier Phase-1 +-- revision created carries DEFAULT 'RECEIVED', and adding columns cannot fix +-- that. An upgraded deployment kept minting rows that skip STAGING entirely — +-- claimable by the worker before their object exists, which is exactly what +-- the two-step upload exists to prevent. Idempotent. +ALTER TABLE "ReceiptIntake" ALTER COLUMN "state" SET DEFAULT 'STAGING'; + +-- ONE LIVE CLAIM PER OBJECT PATH. The primary key IS the invariant: two +-- live claims over one path cannot exist, whatever the application does. +-- Publishing and deleting the same object used to be separated only by +-- claim data inside an AutomationEvent's JSON, which nothing enforced and +-- which two concurrent transactions could each read as 'free'. +CREATE TABLE IF NOT EXISTS "ReceiptObjectClaim" ( + "storagePath" TEXT NOT NULL, + "token" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReceiptObjectClaim_pkey" PRIMARY KEY ("storagePath") +); +CREATE INDEX IF NOT EXISTS "ReceiptObjectClaim_expiresAt_idx" ON "ReceiptObjectClaim"("expiresAt"); +ALTER TABLE "ReceiptObjectClaim" ENABLE ROW LEVEL SECURITY; + +CREATE UNIQUE INDEX IF NOT EXISTS "ReceiptIntake_sourceRef_key" ON "ReceiptIntake"("sourceRef"); +CREATE UNIQUE INDEX IF NOT EXISTS "ReceiptIntake_expenseId_key" ON "ReceiptIntake"("expenseId"); +CREATE UNIQUE INDEX IF NOT EXISTS "ReceiptIntake_dedupStrongKey_active_key" + ON "ReceiptIntake"("dedupStrongKey") + WHERE "dedupStrongKey" IS NOT NULL AND "state" NOT IN ('DUPLICATE', 'VOID'); +CREATE INDEX IF NOT EXISTS "ReceiptIntake_state_nextRetryAt_idx" ON "ReceiptIntake"("state", "nextRetryAt"); +CREATE INDEX IF NOT EXISTS "ReceiptIntake_projectId_idx" ON "ReceiptIntake"("projectId"); +CREATE INDEX IF NOT EXISTS "ReceiptIntake_dedupWeakKey_idx" ON "ReceiptIntake"("dedupWeakKey"); +CREATE INDEX IF NOT EXISTS "ReceiptIntake_createdAt_idx" ON "ReceiptIntake"("createdAt"); +CREATE INDEX IF NOT EXISTS "ReceiptIntake_costCodeId_idx" ON "ReceiptIntake"("costCodeId"); +CREATE INDEX IF NOT EXISTS "ReceiptIntake_createdById_idx" ON "ReceiptIntake"("createdById"); + +-- CONVERGENT, exactly like scripts/apply-receipt-intake.mjs: a constraint that +-- exists with a STALE definition is replaced, not left alone. "Create only when +-- absent" is what let the two diverge — a database that already carried an +-- older state list (one without SHADOW_QUARANTINE, say) kept it forever here +-- while the apply script corrected it in production, so the same repo described +-- two different tables depending on which path built them. +DO $$ +DECLARE current_def TEXT; + wanted_def TEXT := 'CHECK ((state = ANY (ARRAY[''STAGING''::text, ''RECEIVED''::text, ''READ''::text, ''NEEDS_JOB''::text, ''NEEDS_REVIEW''::text, ''BOOKING''::text, ''BOOKED''::text, ''ARCHIVED''::text, ''DUPLICATE''::text, ''VOID''::text, ''NON_RECEIPT''::text, ''SHADOW_DONE''::text, ''SHADOW_QUARANTINE''::text])))'; +BEGIN + SELECT pg_get_constraintdef(oid) INTO current_def + FROM pg_constraint + WHERE conname = 'ReceiptIntake_state_check' + AND conrelid = '"ReceiptIntake"'::regclass; + + IF current_def IS NULL THEN + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_state_check" + CHECK ("state" IN ('STAGING', 'RECEIVED', 'READ', 'NEEDS_JOB', 'NEEDS_REVIEW', 'BOOKING', + 'BOOKED', 'ARCHIVED', 'DUPLICATE', 'VOID', 'NON_RECEIPT', + 'SHADOW_DONE', 'SHADOW_QUARANTINE')); + ELSIF current_def IS DISTINCT FROM wanted_def THEN + -- One statement each, in the SAME transaction as everything else here, + -- so the table is never briefly unconstrained. + ALTER TABLE "ReceiptIntake" DROP CONSTRAINT "ReceiptIntake_state_check"; + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_state_check" + CHECK ("state" IN ('STAGING', 'RECEIVED', 'READ', 'NEEDS_JOB', 'NEEDS_REVIEW', 'BOOKING', + 'BOOKED', 'ARCHIVED', 'DUPLICATE', 'VOID', 'NON_RECEIPT', + 'SHADOW_DONE', 'SHADOW_QUARANTINE')); + END IF; +END $$; + +-- RLS, matching every other sensitive table in this schema. ENABLE with no +-- policies and WITHOUT FORCE: the app connects as the owner/service role, which +-- bypasses RLS, so reads and writes are unaffected — while anon and +-- authenticated roles get nothing. FORCE would deny the owner too. +ALTER TABLE "ReceiptIntake" ENABLE ROW LEVEL SECURITY; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_projectId_fkey' + AND conrelid = '"ReceiptIntake"'::regclass + ) THEN + ALTER TABLE "ReceiptIntake" + ADD CONSTRAINT "ReceiptIntake_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_costCodeId_fkey' + AND conrelid = '"ReceiptIntake"'::regclass + ) THEN + ALTER TABLE "ReceiptIntake" + ADD CONSTRAINT "ReceiptIntake_costCodeId_fkey" + FOREIGN KEY ("costCodeId") REFERENCES "CostCode"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_createdById_fkey' + AND conrelid = '"ReceiptIntake"'::regclass + ) THEN + ALTER TABLE "ReceiptIntake" + ADD CONSTRAINT "ReceiptIntake_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_expenseId_fkey' + AND conrelid = '"ReceiptIntake"'::regclass + ) THEN + ALTER TABLE "ReceiptIntake" + ADD CONSTRAINT "ReceiptIntake_expenseId_fkey" + FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/prisma-blind-spots.json b/prisma/prisma-blind-spots.json index c97d83440..ff378aab8 100644 --- a/prisma/prisma-blind-spots.json +++ b/prisma/prisma-blind-spots.json @@ -22,6 +22,10 @@ "name": "MessageThread_projectId_client_unique", "def": "CREATE UNIQUE INDEX \"MessageThread_projectId_client_unique\" ON public.\"MessageThread\" USING btree (\"projectId\") WHERE (\"subcontractorId\" IS NULL)" }, + { + "name": "ReceiptIntake_dedupStrongKey_active_key", + "def": "CREATE UNIQUE INDEX \"ReceiptIntake_dedupStrongKey_active_key\" ON public.\"ReceiptIntake\" USING btree (\"dedupStrongKey\") WHERE ((\"dedupStrongKey\" IS NOT NULL) AND (state <> ALL (ARRAY['DUPLICATE'::text, 'VOID'::text])))" + }, { "name": "ReviewAlertBatch_claimed_lease_idx", "def": "CREATE INDEX \"ReviewAlertBatch_claimed_lease_idx\" ON public.\"ReviewAlertBatch\" USING btree (\"claimedAt\", \"createdAt\") WHERE (status = 'CLAIMED'::text)" @@ -115,6 +119,11 @@ "table": "\"QboPurchaseClassification\"", "def": "CHECK ((classification = ANY (ARRAY['job-cost'::text, 'overhead'::text, 'owner-draw'::text, 'unknown'::text])))" }, + { + "name": "ReceiptIntake_state_check", + "table": "\"ReceiptIntake\"", + "def": "CHECK ((state = ANY (ARRAY['STAGING'::text, 'RECEIVED'::text, 'READ'::text, 'NEEDS_JOB'::text, 'NEEDS_REVIEW'::text, 'BOOKING'::text, 'BOOKED'::text, 'ARCHIVED'::text, 'DUPLICATE'::text, 'VOID'::text, 'NON_RECEIPT'::text, 'SHADOW_DONE'::text, 'SHADOW_QUARANTINE'::text])))" + }, { "name": "RefundEvent_amountCents_check", "table": "\"RefundEvent\"", @@ -267,6 +276,14 @@ "name": "QboPurchaseClassification", "forced": false }, + { + "name": "ReceiptIntake", + "forced": false + }, + { + "name": "ReceiptObjectClaim", + "forced": false + }, { "name": "RefundEvent", "forced": false diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 89c8556ff..3f84cfbf5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,7 @@ model User { mcpKeys McpKey[] createdInspections Inspection[] @relation("InspectionCreator") percentCompleteUpdates Project[] @relation("PercentCompleteUpdatedBy") + receiptIntakes ReceiptIntake[] lockedPayrollPeriods PayrollPeriod[] @relation("PayrollPeriodLocker") } @@ -398,6 +399,7 @@ model Project { decisions Decision[] permits Permit[] inspections Inspection[] + receiptIntakes ReceiptIntake[] // ── Percent complete (earned revenue / earned margin) ────────────────────── // percentComplete is the EFFECTIVE value every screen shows; percentCompleteAuto @@ -728,6 +730,12 @@ model Expense { createdAt DateTime @default(now()) + /// Back-relation for ReceiptIntake.expenseId. Declared here rather than left + /// SQL-only because `prisma migrate diff` WOULD see a foreign key that + /// schema.prisma does not declare and propose dropping it, which breaks CI's + /// "migrations reproduce production" assertion. + receiptIntake ReceiptIntake? + @@index([estimateId]) @@index([changeOrderId]) } @@ -980,6 +988,7 @@ model CostCode { purchaseOrderItems PurchaseOrderItem[] catalogItems CatalogItem[] scheduleTasks ScheduleTask[] + receiptIntakes ReceiptIntake[] } model CostType { @@ -3179,3 +3188,172 @@ model BankImageMatch { @@index([bankLineId]) } + +/// Receipt Pipeline v2 — the single intake row for one inbound document +/// (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md). Every source (mobile capture, the +/// Apps Script Drive/email/chat forwarders, the web uploader) lands here first; +/// the cron worker reads it with Gemini, dedups it, routes it, and only then +/// books it to QuickBooks + Expense. +/// +/// `state` is a String with a SQL CHECK, not a Prisma enum — matching +/// BankLine.state and Expense.status. +/// ONE LIVE CLAIM PER OBJECT PATH, enforced by the primary key. +/// +/// Publishing and deleting the same object are mutually exclusive, and until +/// now that exclusion lived entirely in the JSON `detail` of an AutomationEvent +/// -- which no constraint could enforce and no two transactions serialized on. +/// A sweeper converting an EXPIRED provisional intent into a deleting claim and +/// a publisher inserting a fresh publishing claim touched DIFFERENT rows, so at +/// READ COMMITTED both read "the path is free", both committed, and the sweeper +/// deleted the object the publisher had just sealed but not yet pointed at: a +/// RECEIVED row with no bytes behind it. +/// +/// The path being the PRIMARY KEY makes a second live claim impossible even if +/// the advisory lock both claim transactions take were somehow missed. +model ReceiptObjectClaim { + /// The object this claim is over. One row per path -- that IS the invariant. + storagePath String @id + /// Random per acquisition. The holder re-reads it before acting, so an + /// expired claim taken over by somebody else cannot be mistaken for its own. + token String + /// publishing | deleting + kind String + /// When the claim lapses. A dead holder must not block a path forever. + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([expiresAt]) +} + +model ReceiptIntake { + id String @id @default(cuid()) + source String // mobile | email | drive | chat | web + /// "drive:" | "email::" | "mobile:" | + /// "chat::" | "web:" — the caller's idempotency key. + sourceRef String @unique + /// STAGING is the state a row is BORN in: the object is not in the bucket + /// yet, so the worker's claim predicate excludes it. The intake route flips + /// it to RECEIVED in one UPDATE after the upload lands. A row that never gets + /// there is swept to NEEDS_REVIEW after 15 minutes. + state String @default("STAGING") // STAGING RECEIVED READ NEEDS_JOB NEEDS_REVIEW BOOKING BOOKED ARCHIVED DUPLICATE VOID NON_RECEIPT SHADOW_DONE SHADOW_QUARANTINE + dryRun Boolean @default(true) + /// no-estimate | multi-doc | zero-total | weak-dup: | + /// strong-dup-amount-mismatch: | qbo-fault: | max-retries | + /// push-disabled | push-paused + stateReason String? + /// tax-implausible, and nothing else. A DURABLE marker: `stateReason` is + /// overwritten by every deferred booking and every park, so a warning left + /// there did not survive to BOOKED. Written once by routing. + taxWarning String? + + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + costCodeId String? + costCode CostCode? @relation(fields: [costCodeId], references: [id]) + suggestedCostCodeId String? + suggestedConfidence Float? + createdById String? + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + // file (Supabase secure-docs, private) + storagePath String // receipts/intake/. in SECURE_BUCKET + fileName String? + mimeType String + fileSize Int + fileSha256 String + /// What the CLIENT said it was about to upload, recorded by /intake/start. + /// The two-step flow hands the bytes straight to storage, so this is the only + /// way to notice that a reused sourceRef is carrying a DIFFERENT document. + expectedSha256 String? + /// When the signed upload URL /intake/start last issued stops working. + /// The sweeper uses THIS, not createdAt, to decide whether an object could + /// still arrive: a row whose URL was re-issued is younger than its row, and + /// parking it on row age declared a receipt missing while its own upload link + /// was live. Null on rows that never had a signed URL (the single-shot path). + uploadUrlExpiresAt DateTime? + /// Bumped every time a signed upload URL is issued for this row, and embedded + /// in the path that URL points at. It is what makes "the upload the sweeper + /// looked at" and "the upload the client is doing now" different things: a + /// resumed /start moves the row to v2 while a sweep is still deciding about + /// v1, and every destructive write fences on the version it observed, so the + /// sweep's verdict lands on nothing. + uploadLeaseVersion Int @default(0) + /// The ADOPTION GENERATION of the current lease: a fresh random value written + /// every time a lease is issued, extended or re-armed for this row. + /// + /// It exists because /start's own discard CAS needed a column that provably + /// MOVES on adoption and it had none. That CAS pinned `uploadUrlExpiresAt`, + /// but a reuse writes "now + 2h" exactly as the original issue did — so two + /// requests landing in the same millisecond (or on hosts whose clocks agree + /// too well) wrote the SAME expiry, the CAS matched, and the delete removed a + /// row another request had already adopted and handed a working URL for. The + /// version cannot serve instead: it is embedded in the object path, so + /// bumping it on a same-path reuse would break the lease-reuse contract. + uploadLeaseNonce String? + + // read results (cents, like AutomationEvent) + vendor String? + txnDate DateTime? @db.Date + totalCents Int? + taxCents Int? + docType String? // receipt | check | multi | non_receipt + refNumber String? // cleaned invoice #, or "Check" for checks + memo String? + readJson String? // raw Gemini JSON, audit only + readAt DateTime? + + // dedup + dedupStrongKey String? + dedupWeakKey String? + duplicateOfId String? + + // booking + archive + /// Set the instant before the QBO create is attempted. A park BEFORE this is + /// true provably created no Purchase, so its strong key can be released; a + /// park after it must keep the key, because QuickBooks may hold a Purchase + /// whose response we lost. + sendAttempted Boolean @default(false) + /// Positive evidence that v1 (the Apps Script) already booked this document. + /// The cutover retires ONLY rows carrying it — never rows that merely predate + /// the boundary, because "old" is not proof anybody booked anything. + archivedByV1 Boolean @default(false) + qbPurchaseId String? + expenseId String? @unique + expense Expense? @relation(fields: [expenseId], references: [id], onDelete: SetNull) + archiveDriveFileId String? + /// Fencing token for the worker's claim, rewritten on every claim. + /// + /// `nextRetryAt` is a time-based lease and cannot tell a LIVE worker from a + /// stale one whose invocation was killed and resumed: both hold the same row + /// id and both believe they own it. The token can — a completing transition + /// matches on it, so a worker whose claim was superseded writes nothing + /// instead of overwriting the state its successor just produced. + claimToken String? + claimedAt DateTime? + attempts Int @default(0) + /// Consecutive passes where the AI service was UNAVAILABLE (never the + /// document's fault, so it must not spend `attempts`). Ported from v3.4: + /// an outage that never ends still has to end somewhere, so after 20 the + /// row is parked for a human instead of retrying forever. + busyPasses Int @default(0) + lastError String? + nextRetryAt DateTime? + bookedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // NOTE: a partial UNIQUE index on dedupStrongKey (WHERE state NOT IN + // ('DUPLICATE','VOID') AND dedupStrongKey IS NOT NULL) exists in SQL only — + // Prisma cannot represent partial indexes and silently drops them (CLAUDE.md, + // baseline notes). Keep this comment; never regenerate it away. + @@index([state, nextRetryAt]) + @@index([projectId]) + /// Both are FK targets the queue filters and joins on; without these a + /// cost-code or user-scoped read scans the whole table once it has volume. + @@index([costCodeId]) + @@index([createdById]) + @@index([dedupWeakKey]) + @@index([createdAt]) +} diff --git a/scripts/apply-receipt-intake.mjs b/scripts/apply-receipt-intake.mjs new file mode 100644 index 000000000..086735fb7 --- /dev/null +++ b/scripts/apply-receipt-intake.mjs @@ -0,0 +1,1242 @@ +// One-off additive migration for ReceiptIntake (Receipt Pipeline v2, Phase 1 — +// docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §2): the single intake row for one +// inbound receipt/check document, from every source (mobile capture, the Apps +// Script Drive/email/chat forwarders, the web uploader). +// +// The SQL here is byte-equivalent to +// prisma/migrations/20260901000000_receipt_intake/migration.sql — that file is +// what a fresh CI/dev database gets; this script is what production gets, +// BEFORE the build that selects these columns deploys (CLAUDE.md pre-deploy +// rule #2 — otherwise every page touching them throws P2022). +// +// Two objects are invisible to Prisma and must be created here, not by the +// generator: +// * CHECK ("state" IN (...)) — Prisma has no check-constraint concept. +// * the PARTIAL unique index on "dedupStrongKey" — Prisma's diff engine drops +// partial indexes without comment. It is not an optimisation: it IS the +// strong-dedup claim. The reader writes the keys and reads a unique +// violation as "another live row already owns this purchase", which is what +// replaces the Apps Script's Script-Properties lock. +// +// Additive and idempotent: CREATE TABLE / INDEX IF NOT EXISTS plus guarded +// constraint adds. Safe to re-run; a second run reports every statement "ok" +// and changes nothing. No existing table is touched. +// +// node scripts/apply-receipt-intake.mjs --target prod --yes \ +// --expect-db --expect-host +// +// --target prod is REQUIRED and it is what decides which database this +// talks to. Without it the script read an AMBIENT DATABASE_URL first, so a +// developer with a local one exported in their shell could run this, watch +// every statement report ok against their own Postgres, and merge believing +// production had been migrated. `--target prod` reads +// .env.production.local and nothing else -- an ambient DATABASE_URL is +// ignored, not preferred. +// +// APPLY_EXPECT_PROJECT_REF must also be exported, and it is what actually +// pins the DATABASE. Supabase's pooler hostnames are shared regionally and +// every Supabase database is called `postgres`, so host + name + migration +// history cannot tell production from a staging clone migrated off the same +// baseline. The project ref lives in the connection URL's USERNAME +// (`postgres.`); this script parses it, compares it, prints it, +// and refuses when the variable is unset. +// +// --expect-db and --expect-host are BOTH required alongside --yes, matching +// scripts/apply-bank-image.mjs: "--yes" alone only proves you meant to run +// something, and a database NAME alone doesn't prove which SERVER it's on. +import { PrismaClient } from "@prisma/client"; +import dns from "node:dns"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** The only file --target prod will read a URL out of. */ +export const PROD_ENV_FILE = ".env.production.local"; + +/** + * The baseline migration production is known to carry + * (prisma/migrations/20260814000000_baseline_production, marked applied in + * prod's _prisma_migrations by a deliberate one-off step). A database + * WITHOUT this row is not production, whatever its name is. + */ +export const PROD_BASELINE_MIGRATION = "20260814000000_baseline_production"; + +/** Production reaches Postgres through Supabase's pooler, never directly. */ +export const PROD_POOLER_HOST_SUFFIX = ".pooler.supabase.com"; + +/** + * THE ENV VAR THAT NAMES THE PROJECT. Shared by every apply-*.mjs, so one + * export covers them all and none of them can drift to its own spelling. + */ +export const PROJECT_REF_ENV = "APPLY_EXPECT_PROJECT_REF"; + +/** + * WHICH SUPABASE PROJECT IS THIS URL FOR? + * + * Host, database name and migration history are NOT enough to tell + * production from a staging clone that was migrated from the same + * baseline: pooler hosts are shared regionally, so two projects in + * us-west-2 present the SAME hostname, and every Supabase database is + * called `postgres`. The only thing in the connection string that names the + * project is the USERNAME, which the pooler requires in the form + * `postgres.`. + */ +export function projectRefOf(url) { + let username; + try { + username = decodeURIComponent(new URL(url).username); + } catch { + return ""; + } + const dot = username.indexOf("."); + return dot > 0 ? username.slice(dot + 1) : ""; +} + +/** + * WHICH DATABASE IS THIS RUN FOR? Decided from argv alone, never from the + * environment. + * + * The old resolver preferred `process.env.DATABASE_URL`, which meant the + * answer depended on whatever the operator happened to have exported. A + * developer with a local URL in their shell got a clean, green run against + * their own database and no signal at all that production was untouched. + * There is exactly one target and it has to be asked for by name. + * + * @param {string[]} argv + * @returns {{ ok: true, target: "prod" } | { ok: false, reason: string }} + */ +export function chooseTarget(argv) { + const at = argv.indexOf("--target"); + const value = at >= 0 ? argv[at + 1] : undefined; + if (!value) { + return { + ok: false, + reason: "Refusing to run without --target prod. An ambient DATABASE_URL is NOT a target.", + }; + } + if (value !== "prod" && value !== "ci") { + return { ok: false, reason: `Unknown --target ${value}. Targets are prod and ci.` }; + } + return { ok: true, target: value }; +} + +/** + * The URL for a chosen target. `.env.production.local` ONLY -- an ambient + * DATABASE_URL is deliberately not consulted, and does not override it. + * + * @param {string} target + * @param {(file: string, encoding: string) => string} [readFile] + * @param {(file: string) => boolean} [exists] + * @returns {{ url: string, from: string }} + */ +export function resolveTargetUrl(target, readFile = fs.readFileSync, exists = fs.existsSync) { + if (target === "ci") { + // THE ONE PLACE THE AMBIENT URL IS READ, and it is fenced two ways: + // the caller had to ask for `--target ci` by name, and a URL that + // looks like Supabase is refused outright. The prod guard cannot be + // satisfied through this path even by accident -- `ci` never checks + // the baseline row or the project ref, so it could not stand in for + // it, and this refusal means it cannot reach a Supabase database at + // all. + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is required for --target ci"); + if (looksLikeSupabase(url)) { + throw new Error(`REFUSING: --target ci was given a Supabase URL (${hostOf(url)})`); + } + return { url, from: "the ambient environment (--target ci)" }; + } + if (target !== "prod") throw new Error(`no URL source for target ${target}`); + if (!exists(PROD_ENV_FILE)) { + throw new Error(`${PROD_ENV_FILE} not found. Run \`vercel env pull ${PROD_ENV_FILE} --environment=production\` first.`); + } + const match = String(readFile(PROD_ENV_FILE, "utf8")).match(/^DATABASE_URL\s*=\s*"?([^"\n]+)"?/m); + if (!match) throw new Error(`DATABASE_URL not found in ${PROD_ENV_FILE}`); + return { url: match[1], from: PROD_ENV_FILE }; +} + +/** + * Does this URL point at Supabase? Used only to REFUSE -- `--target ci` is + * for a throwaway container and must never be able to reach a real project, + * pooler or direct. + */ +export function looksLikeSupabase(url) { + return /supabase\.(co|com)/i.test(String(url)); +} + +/** The URL's hostname, or an empty string if it will not parse. */ +export function hostOf(url) { + try { + return new URL(url).hostname; + } catch { + return ""; + } +} + +/** + * THE TARGET LINE, printed before the first statement and in --dry-run. + * + * Host, database and whether the production baseline migration is there -- + * and nothing else. No credentials pass through here: the caller hands it + * the hostname it parsed and the name the SERVER reported, never the URL. + */ +export function targetLine({ host, database, projectRef, baseline }) { + return [ + `TARGET host=${host}`, + `project=${projectRef || "(none)"}`, + `database=${database}`, + `baseline=${baseline ? "present" : "MISSING"}`, + ].join(" "); +} + +/** + * Is the thing we just connected to actually production? + * + * Three independent facts, because each alone is forgeable by accident: the + * URL goes through the pooler, the server names a database, and that + * database carries the baseline migration row. A local Postgres can be + * called anything; it cannot have prod's migration history. + */ +/** + * @param {(sql: string, ...args: unknown[]) => Promise} query + * @param {string} urlHost + * @param {string} projectRef parsed from the URL username + * @param {string | undefined} expectRef APPLY_EXPECT_PROJECT_REF + */ +export async function verifyProdIdentity(query, urlHost, projectRef, expectRef, target = "prod") { + const problems = []; + const [row0] = target === "ci" + ? await query("SELECT current_database() AS db, COALESCE(host(inet_server_addr()), '') AS host") + : [null]; + if (target === "ci") { + // The mirror image of the prod checks: this one proves the target is + // NOT production. No baseline row is required (the point is to build + // the schema from nothing) and no project ref exists. + if (looksLikeSupabase(urlHost)) { + problems.push(`REFUSING: --target ci was pointed at ${urlHost}`); + } + const db = String(row0?.db ?? ""); + if (!db) problems.push("the server did not report a database name"); + return { + problems, + actual: row0, + line: targetLine({ + host: urlHost, + database: db, + projectRef: "(ci)", + baseline: false, + }), + }; + } + if (!urlHost.endsWith(PROD_POOLER_HOST_SUFFIX)) { + problems.push(`host ${urlHost || "(unparseable)"} is not a ${PROD_POOLER_HOST_SUFFIX} pooler host`); + } + // THE PROJECT REF IS THE ONLY THING THAT SEPARATES PRODUCTION FROM A + // MIGRATED CLONE. Unset is a refusal, never a skip: a check that turns + // itself off when a variable is missing is the check not existing. + if (!expectRef) { + problems.push(`${PROJECT_REF_ENV} is not set: nothing identifies WHICH Supabase project this is`); + } else if (!projectRef) { + problems.push("the URL username carries no project ref (expected postgres.)"); + } else if (projectRef !== expectRef) { + problems.push(`project ${projectRef} is not ${expectRef}: same pooler host, different project`); + } + const [row] = await query("SELECT current_database() AS db, COALESCE(host(inet_server_addr()), '') AS host"); + const database = String(row?.db ?? ""); + if (!database) problems.push("the server did not report a database name"); + const found = await query( + "SELECT migration_name FROM _prisma_migrations WHERE migration_name = $1", + PROD_BASELINE_MIGRATION, + ).catch(() => []); + const baseline = Array.isArray(found) && found.length > 0; + if (!baseline) { + problems.push(`_prisma_migrations has no ${PROD_BASELINE_MIGRATION} row: this is not production`); + } + return { + problems, + actual: row, + line: targetLine({ host: urlHost, database, projectRef, baseline }), + }; +} + +/** + * REDACT THE CREDENTIALS, WITHOUT PARSING THE URL BY HAND. + * + * The regex this replaces was `/:[^:@]*@/` -> `:****@`, and it leaked whenever + * the password contained a literal colon. For + * `postgresql://user:pa:ss@host/db` it matched only the LAST `:ss@` segment, + * printing `postgresql://user:pa:****@host/db` — the first half of the + * password, in a log line the operator is told to paste into tickets. + * Passwords with `:` are ordinary (Supabase generates them), so this was not a + * corner case. + * + * `new URL()` knows where the userinfo ends; a regex cannot, because `@` is + * legal inside a percent-encoded password and `:` is legal inside it verbatim. + * Both halves of the userinfo go, since a username is an account name too. + * + * A URL that will not parse is NOT echoed in any form. There is nothing useful + * to show — the string is malformed, so any substring of it could be anything + * — and printing "the part I could not parse" is how a redactor leaks the + * thing it exists to hide. + */ +export function maskUrl(url) { + try { + const parsed = new URL(url); + // NO HOST MEANS WE COULD NOT LOCATE THE USERINFO. `postgres:/x@y` + // parses as an opaque path with empty username and password, so the + // redaction would be a no-op and the whole string echoed verbatim. A + // real DATABASE_URL always has a host; anything without one is + // malformed, and a malformed string is exactly what must not be + // printed on the guess that its `@` is not a credential boundary. + if (!parsed.host) return ""; + if (parsed.password) parsed.password = "***"; + if (parsed.username) parsed.username = "***"; + return parsed.toString(); + } catch { + return ""; + } +} + +function readFlagValue(flag) { + const idx = process.argv.indexOf(flag); + return idx >= 0 ? process.argv[idx + 1] : undefined; +} + +/** + * Pure comparison, exported for unit testing without a live DB. Compares BOTH + * database name and server host, and both EXACTLY. + * + * apply-bank-image.mjs accepts a substring match on the host "because a pooled + * Supabase host resolves to an IP". That is a guard which gets LOOSER the + * shorter the operator's input is: `--expect-host 1` satisfies `host.includes` + * against 10.0.0.5, 172.16.1.1, and almost anything else. A guard whose whole + * job is to stop DDL landing on the wrong server must not have a degenerate + * case, so this one is exact. Print `host(inet_server_addr())` (the script logs + * it before refusing) and pass that value. + */ +export function targetMatches(actual, expectDb, expectHost) { + if (!actual || typeof actual !== "object") return false; + if (String(actual.db ?? "") !== String(expectDb ?? "")) return false; + return String(actual.host ?? "") === String(expectHost ?? ""); +} + +/** `localhost` is these two addresses by definition -- no lookup, and none in tests. */ +export const LOOPBACK_ADDRESSES = ["127.0.0.1", "::1"]; + +/** + * An IPv4-mapped IPv6 address is an IPv4 address written another way. + * + * Postgres reports `::ffff:10.0.0.5` or `10.0.0.5` for the same server + * depending on how the listener is bound, and a resolver answers with the plain + * form, so both sides are normalised before they are compared. + */ +export function normalizeServerHost(host) { + const value = String(host ?? "").trim(); + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(value); + return mapped ? mapped[1] : value; +} + +/** The real resolver. Used only on the live path; every test injects its own. */ +export async function lookupAddresses(hostname) { + const records = await dns.promises.lookup(hostname, { all: true }); + return records.map(record => record.address); +} + +/** + * THROUGH THE POOLER, THE SERVER THAT ANSWERS IS THE PROJECT'S OWN DATABASE. + * + * Measured 2026-09-04, not assumed: aws-0-us-west-2.pooler.supabase.com + * resolves to three IPv4 addresses (54.70.143.232, 35.160.209.8, + * 44.238.118.41) and publishes no AAAA at all, while production's + * `inet_server_addr()` reports 2600:1f13:838:6e45:7ee0:268:15b9:d263 -- which is + * exactly the AAAA of db.ghzdbzdnwjxazvmcefbh.supabase.co. Supavisor hands the + * session to the project database, and it is that database, not the pooler, + * that answers `current_database()` and `inet_server_addr()`. So resolving + * --expect-host ALONE still refuses production: the project's direct host has + * to be resolved too. + * + * This sharpens the guard rather than weakening it. `` comes from + * the URL username, and verifyProdIdentity has already refused unless it equals + * APPLY_EXPECT_PROJECT_REF -- so accepting this address says "you reached the + * database of the project you named", which is a stronger claim than "you + * reached something behind the regional pooler", which is shared. + * + * Empty unless the URL is a production pooler URL carrying a parseable ref: + * there is nothing to widen to, and nothing is widened. + */ +export function directDbHostForUrl(url) { + const ref = projectRefOf(url); + if (!ref) return ""; + const hostname = hostOf(url); + return hostname.toLowerCase().endsWith(PROD_POOLER_HOST_SUFFIX) ? `db.${ref}.supabase.co` : ""; +} + +/** + * THE SERVER ANSWERS WITH AN ADDRESS; `--expect-host` IS A NAME. + * + * `host(inet_server_addr())` is the server's own IP -- through the Supabase + * pooler an IPv6 literal such as 2600:1f13:838:6e45:7ee0:268:15b9:d263 -- while + * the operator passes the hostname out of DATABASE_URL, + * aws-0-us-west-2.pooler.supabase.com. Compared as strings those can never be + * equal, so the exact guard refused production every time, printing exactly + * that. The fix is not to loosen the comparison: it is to resolve the NAME and + * require the connected ADDRESS to be in that set. + * + * Returns the rule that accepted the host, or NULL. **Null is the refusal** -- + * a falsy return must stop the run. The label exists so the banner can say + * WHICH rule accepted the target rather than merely that something did: + * + * "exact" the operator typed the literal address the server reported. + * "loopback" --expect-host is localhost and the server answered from 127.0.0.1 / ::1. + * "dns" --expect-host resolves to the address the server answered from. + * "project-db" `directHost` -- the project's own database, which is what + * answers behind the pooler -- resolves to that address. + * "unix-socket" the server reported NO address (a local socket has none) and + * the URL's own hostname is the expected one. + * + * `resolve` is injected so unit tests never touch DNS. `urlHostname` is + * consulted for the socket case ONLY: it describes what was DIALLED rather than + * what answered, so it is the weakest of the five and is never allowed to + * rescue a connection that did report an address. `directHost` is passed in + * (from directDbHostForUrl) rather than derived here, so this function knows + * nothing about Supabase and a caller cannot widen the set by accident. + */ +export async function targetHostMatches(actualHost, expectHost, resolve = lookupAddresses, urlHostname = "", directHost = "") { + const expect = String(expectHost ?? "").trim(); + // Nothing to verify against is a refusal, never a match. (main() already + // requires --expect-host; this makes the helper safe on its own terms.) + if (!expect) return null; + if (String(actualHost ?? "") === String(expectHost ?? "")) return "exact"; + const actual = normalizeServerHost(actualHost); + if (actual === "") { + return String(urlHostname ?? "").trim().toLowerCase() === expect.toLowerCase() ? "unix-socket" : null; + } + if (/^localhost$/i.test(expect)) return LOOPBACK_ADDRESSES.includes(actual) ? "loopback" : null; + const resolvesToActual = async name => { + const addresses = await resolve(name); + return (addresses ?? []).some(address => normalizeServerHost(address) === actual); + }; + if (await resolvesToActual(expect)) return "dns"; + const direct = String(directHost ?? "").trim(); + if (direct && await resolvesToActual(direct)) return "project-db"; + return null; +} + +/** The closed set of states the CHECK constraint allows. Exported for tests. */ +export const RECEIPT_INTAKE_STATES = [ + "STAGING", "RECEIVED", "READ", "NEEDS_JOB", "NEEDS_REVIEW", "BOOKING", + "BOOKED", "ARCHIVED", "DUPLICATE", "VOID", "NON_RECEIPT", + // Received during the shadow week, therefore booked by v1 and NEVER by v2. + "SHADOW_DONE", + // Pre-boundary, no v1 evidence, and no Drive identity to make a v2 booking + // idempotent. A HUMAN decides. + "SHADOW_QUARANTINE", +]; + +export const statements = [ + `CREATE TABLE IF NOT EXISTS "ReceiptIntake" ( + "id" TEXT NOT NULL, + "source" TEXT NOT NULL, + "sourceRef" TEXT NOT NULL, + "state" TEXT NOT NULL DEFAULT 'STAGING', + "dryRun" BOOLEAN NOT NULL DEFAULT true, + "stateReason" TEXT, + "taxWarning" TEXT, + "projectId" TEXT, + "costCodeId" TEXT, + "suggestedCostCodeId" TEXT, + "suggestedConfidence" DOUBLE PRECISION, + "createdById" TEXT, + "storagePath" TEXT NOT NULL, + "fileName" TEXT, + "mimeType" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "fileSha256" TEXT NOT NULL, + "expectedSha256" TEXT, + "uploadUrlExpiresAt" TIMESTAMP(3), + "uploadLeaseVersion" INTEGER NOT NULL DEFAULT 0, + "uploadLeaseNonce" TEXT, + "vendor" TEXT, + "txnDate" DATE, + "totalCents" INTEGER, + "taxCents" INTEGER, + "docType" TEXT, + "refNumber" TEXT, + "memo" TEXT, + "readJson" TEXT, + "readAt" TIMESTAMP(3), + "dedupStrongKey" TEXT, + "dedupWeakKey" TEXT, + "duplicateOfId" TEXT, + "sendAttempted" BOOLEAN NOT NULL DEFAULT false, + "archivedByV1" BOOLEAN NOT NULL DEFAULT false, + "qbPurchaseId" TEXT, + "expenseId" TEXT, + "archiveDriveFileId" TEXT, + "claimToken" TEXT, + "claimedAt" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "busyPasses" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextRetryAt" TIMESTAMP(3), + "bookedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReceiptIntake_pkey" PRIMARY KEY ("id") + )`, + + // Additive upgrade for a table created by an EARLIER run of this script, + // before busyPasses existed: CREATE TABLE IF NOT EXISTS is a no-op on an + // existing table, so a column added to the CREATE above would never reach + // it. This is the whole reason the script is re-runnable. + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "busyPasses" INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "expectedSha256" TEXT`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadUrlExpiresAt" TIMESTAMP(3)`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadLeaseVersion" INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadLeaseNonce" TEXT`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "sendAttempted" BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "archivedByV1" BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "claimToken" TEXT`, + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "claimedAt" TIMESTAMP(3)`, + + // The dropped-tax-reading marker's DURABLE home. It used to live in + // `stateReason`, which every deferred booking and every park overwrites, + // so a receipt that booked through the deferred path -- which is every + // receipt during the disabled-push cutover -- reached BOOKED with the + // warning already erased. + `ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "taxWarning" TEXT`, + + // THE STATE DEFAULT IS REPAIRED, not just declared on a fresh table. + // + // `CREATE TABLE IF NOT EXISTS` above carries DEFAULT 'STAGING', and a table + // this script created in an EARLIER Phase-1 revision carries the old + // DEFAULT 'RECEIVED'. Adding columns cannot fix that, so an upgraded + // deployment kept minting rows that skip STAGING entirely: they are + // claimable by the worker the instant they are inserted, before their + // object exists, which is the state the two-step upload exists to prevent. + // Idempotent — setting a default that already matches is a no-op. + `ALTER TABLE "ReceiptIntake" ALTER COLUMN "state" SET DEFAULT 'STAGING'`, + + // ONE LIVE CLAIM PER OBJECT PATH. The primary key IS the invariant. + // + // Publishing and deleting the same object are mutually exclusive, and + // that exclusion used to live entirely in an AutomationEvent's JSON + // `detail` -- which no constraint enforced and which two concurrent + // transactions could each read as 'free', because they touched different + // rows. Both claim transactions now take a per-path advisory lock AND + // write here, so a second live claim is impossible even if the lock were + // somehow missed. + `CREATE TABLE IF NOT EXISTS "ReceiptObjectClaim" ( + "storagePath" TEXT NOT NULL, + "token" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReceiptObjectClaim_pkey" PRIMARY KEY ("storagePath") + )`, + + `CREATE INDEX IF NOT EXISTS "ReceiptObjectClaim_expiresAt_idx" + ON "ReceiptObjectClaim"("expiresAt")`, + + `ALTER TABLE "ReceiptObjectClaim" ENABLE ROW LEVEL SECURITY`, + + // Intake idempotency: one row per caller-supplied sourceRef. A forwarder + // replaying the same Drive file / Gmail message is a no-op. + `CREATE UNIQUE INDEX IF NOT EXISTS "ReceiptIntake_sourceRef_key" + ON "ReceiptIntake"("sourceRef")`, + + // One intake row per booked Expense. + `CREATE UNIQUE INDEX IF NOT EXISTS "ReceiptIntake_expenseId_key" + ON "ReceiptIntake"("expenseId")`, + + // THE STRONG-DEDUP CLAIM (partial — Prisma cannot express this). Quarantined + // rows (DUPLICATE) and voided ones drop out of the index so the surviving + // original keeps the key. + `CREATE UNIQUE INDEX IF NOT EXISTS "ReceiptIntake_dedupStrongKey_active_key" + ON "ReceiptIntake"("dedupStrongKey") + WHERE "dedupStrongKey" IS NOT NULL AND "state" NOT IN ('DUPLICATE', 'VOID')`, + + // The worker's claim query: state + due time. + `CREATE INDEX IF NOT EXISTS "ReceiptIntake_state_nextRetryAt_idx" + ON "ReceiptIntake"("state", "nextRetryAt")`, + + `CREATE INDEX IF NOT EXISTS "ReceiptIntake_projectId_idx" + ON "ReceiptIntake"("projectId")`, + + // The weak-dedup net is a plain lookup, never a claim. + `CREATE INDEX IF NOT EXISTS "ReceiptIntake_dedupWeakKey_idx" + ON "ReceiptIntake"("dedupWeakKey")`, + + `CREATE INDEX IF NOT EXISTS "ReceiptIntake_createdAt_idx" + ON "ReceiptIntake"("createdAt")`, + + // Both are FK targets the queue filters and joins on. + `CREATE INDEX IF NOT EXISTS "ReceiptIntake_costCodeId_idx" + ON "ReceiptIntake"("costCodeId")`, + + `CREATE INDEX IF NOT EXISTS "ReceiptIntake_createdById_idx" + ON "ReceiptIntake"("createdById")`, + + // state is a closed set — a typo must fail loudly rather than create a + // silent eleventh state that no query ever selects. + // The state set GROWS. `IF NOT EXISTS` alone is wrong for that: a database + // that already has the constraint from an earlier run keeps the OLD set, so + // the first write of a newly-added state (SHADOW_DONE, at cutover, inside + // the claim transaction) fails and takes the whole cutover with it — and + // the script that was supposed to prevent exactly this reported "ok". + // + // So compare the DEFINITION, and replace it when it differs. Postgres + // validates the new CHECK against existing rows as part of the ADD, so a + // set that would orphan live data fails loudly here rather than later. + `DO $$ + DECLARE current_def TEXT; + wanted_def TEXT := 'CHECK ((state = ANY (ARRAY[''STAGING''::text, ''RECEIVED''::text, ''READ''::text, ''NEEDS_JOB''::text, ''NEEDS_REVIEW''::text, ''BOOKING''::text, ''BOOKED''::text, ''ARCHIVED''::text, ''DUPLICATE''::text, ''VOID''::text, ''NON_RECEIPT''::text, ''SHADOW_DONE''::text, ''SHADOW_QUARANTINE''::text])))'; + BEGIN + SELECT pg_get_constraintdef(oid) INTO current_def + FROM pg_constraint + WHERE conname = 'ReceiptIntake_state_check' + AND conrelid = '"ReceiptIntake"'::regclass; + + IF current_def IS NULL THEN + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_state_check" + CHECK ("state" IN ('STAGING', 'RECEIVED', 'READ', 'NEEDS_JOB', 'NEEDS_REVIEW', 'BOOKING', + 'BOOKED', 'ARCHIVED', 'DUPLICATE', 'VOID', 'NON_RECEIPT', + 'SHADOW_DONE', 'SHADOW_QUARANTINE')); + ELSIF current_def IS DISTINCT FROM wanted_def THEN + -- One statement each, in the SAME transaction as everything else this + -- script runs, so the table is never briefly unconstrained. + ALTER TABLE "ReceiptIntake" DROP CONSTRAINT "ReceiptIntake_state_check"; + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_state_check" + CHECK ("state" IN ('STAGING', 'RECEIVED', 'READ', 'NEEDS_JOB', 'NEEDS_REVIEW', 'BOOKING', + 'BOOKED', 'ARCHIVED', 'DUPLICATE', 'VOID', 'NON_RECEIPT', + 'SHADOW_DONE', 'SHADOW_QUARANTINE')); + END IF; + END $$`, + + // SET NULL on every parent: losing a project, cost code, user, or expense + // must never delete the audit trail of a document that was already booked. + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_projectId_fkey' + AND conrelid = '"ReceiptIntake"'::regclass) THEN + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + END $$`, + + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_costCodeId_fkey' + AND conrelid = '"ReceiptIntake"'::regclass) THEN + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_costCodeId_fkey" + FOREIGN KEY ("costCodeId") REFERENCES "CostCode"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + END $$`, + + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_createdById_fkey' + AND conrelid = '"ReceiptIntake"'::regclass) THEN + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + END $$`, + + // RLS, matching every other sensitive table in this schema + // (apply-bank-ledger.mjs, apply-automation-events.mjs, + // apply-deposit-ingest-schema.mjs). ENABLE with no policies and WITHOUT + // FORCE: the app connects as the owner/service role, which bypasses RLS, so + // reads and writes are unaffected — while anon and authenticated roles + // (a leaked anon key, a Supabase client someone wires up later) get nothing. + // FORCE would deny the owner too and take the pipeline down. + // ReceiptIntake holds vendor names, amounts and storage paths for real + // purchases, so it belongs in the same class as BankLine. + `ALTER TABLE "ReceiptIntake" ENABLE ROW LEVEL SECURITY`, + + `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'ReceiptIntake_expenseId_fkey' + AND conrelid = '"ReceiptIntake"'::regclass) THEN + ALTER TABLE "ReceiptIntake" ADD CONSTRAINT "ReceiptIntake_expenseId_fkey" + FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + END $$`, +]; + +export const expectedColumns = { + ReceiptObjectClaim: [ + "storagePath", "token", "kind", "expiresAt", "createdAt", "updatedAt", + ], + ReceiptIntake: [ + "id", "source", "sourceRef", "state", "dryRun", "stateReason", "taxWarning", + "projectId", "costCodeId", "suggestedCostCodeId", "suggestedConfidence", + "createdById", "storagePath", "fileName", "mimeType", "fileSize", + "fileSha256", "expectedSha256", "uploadUrlExpiresAt", "uploadLeaseVersion", + "uploadLeaseNonce", + "sendAttempted", "archivedByV1", + "vendor", "txnDate", "totalCents", "taxCents", "docType", + "refNumber", "memo", "readJson", "readAt", "dedupStrongKey", + "dedupWeakKey", "duplicateOfId", "qbPurchaseId", "expenseId", + "archiveDriveFileId", "claimToken", "claimedAt", + "attempts", "busyPasses", "lastError", "nextRetryAt", + "bookedAt", "createdAt", "updatedAt", + ], +}; + +/** + * A NAME IS NOT A CONSTRAINT. + * + * The verification used to look each of these up by `conname` ALONE and, apart + * from the state CHECK, assert nothing about what came back. Two ways that + * passed while the table was wrong: + * + * - `pg_constraint` is database-wide, not per-table. A constraint of the same + * name on ANY other relation satisfied the lookup, so a run against a + * database where `ReceiptIntake` never got its foreign keys could still + * report "verified 5 constraints". Every lookup is scoped to + * `ReceiptIntake` now, exactly like the DDL guards above already are. + * - Even on the right table, existence says nothing about the TARGET or the + * ACTIONS. An FK left over from an earlier shape (pointing at the wrong + * parent, or ON DELETE CASCADE instead of SET NULL) is the difference + * between "losing a project nulls a column" and "losing a project DELETES + * the audit trail of a booked receipt" — which is the one thing the comment + * above the DDL says must never happen. So each FK's full definition is + * compared: referencing column, referenced table and column, and both + * referential actions. + * + * These fields mirror the ALTER TABLE statements above one for one, and + * tests/apply-receipt-intake.test.ts asserts that parity against both the + * script's own SQL and the committed migration, so the expectation cannot drift + * away from what is actually applied. + */ +export const expectedConstraints = [ + { name: "ReceiptIntake_state_check", table: "ReceiptIntake", kind: "check" }, + { + name: "ReceiptIntake_projectId_fkey", table: "ReceiptIntake", kind: "fk", + column: "projectId", references: "Project", referencedColumn: "id", + onDelete: "SET NULL", onUpdate: "CASCADE", + }, + { + name: "ReceiptIntake_costCodeId_fkey", table: "ReceiptIntake", kind: "fk", + column: "costCodeId", references: "CostCode", referencedColumn: "id", + onDelete: "SET NULL", onUpdate: "CASCADE", + }, + { + name: "ReceiptIntake_createdById_fkey", table: "ReceiptIntake", kind: "fk", + column: "createdById", references: "User", referencedColumn: "id", + onDelete: "SET NULL", onUpdate: "CASCADE", + }, + { + name: "ReceiptIntake_expenseId_fkey", table: "ReceiptIntake", kind: "fk", + column: "expenseId", references: "Expense", referencedColumn: "id", + onDelete: "SET NULL", onUpdate: "CASCADE", + }, +]; + +/** + * SCOPED TO THE TABLE. `$1` is the constraint name; the relation is pinned in + * the SQL itself, the same `'"ReceiptIntake"'::regclass` the DDL guards use. + */ +export const CONSTRAINT_LOOKUP_SQL = + `SELECT pg_get_constraintdef(oid) AS def + FROM pg_constraint + WHERE conname = $1 + AND conrelid = '"ReceiptIntake"'::regclass`; + +/** Every referential action Postgres can render, longest first so the match is greedy enough. */ +const FK_ACTIONS = "NO ACTION|SET DEFAULT|SET NULL|RESTRICT|CASCADE"; + +/** `"a", "b"` / `a` -> ["a", "b"] / ["a"]. pg quotes an identifier only when it must. */ +function identList(raw) { + return raw + .split(",") + .map(part => part.trim().replace(/^"(.*)"$/, "$1")) + .filter(Boolean); +} + +/** + * Compare a live `pg_get_constraintdef` rendering against what the migration + * says the foreign key is. Returns a human description of every difference, or + * null when they agree. + * + * Parsed rather than string-compared on purpose: pg's rendering is not ours to + * predict (identifier quoting depends on the identifier, the clause order is + * pg's own, and a schema qualification appears only when the relation is not on + * the search path). An exact-string expectation would fail on rendering rather + * than on drift, which teaches everyone to ignore it. + * + * An ABSENT action clause means the SQL default, NO ACTION — not "unspecified". + * That distinction is the whole point here: an FK created without ON DELETE + * behaves as NO ACTION, and NO ACTION is exactly the value that would block a + * project delete instead of nulling the column. + */ +export function foreignKeyDrift(expected, def) { + if (typeof def !== "string") return "no definition returned"; + const shape = /^FOREIGN KEY\s*\((.+?)\)\s*REFERENCES\s+(.+?)\s*\((.+?)\)\s*(.*)$/.exec(def.trim()); + if (!shape) return `not a FOREIGN KEY definition: ${def}`; + const [, columns, referenced, referencedColumns, tail] = shape; + + const actionFor = keyword => { + const found = new RegExp(`ON ${keyword}\\s+(${FK_ACTIONS})`, "i").exec(tail); + return (found ? found[1] : "NO ACTION").toUpperCase(); + }; + + const problems = []; + const check = (label, actual, want) => { + if (actual !== want) problems.push(`${label} is ${actual}, want ${want}`); + }; + check("column", identList(columns).join(", "), expected.column); + check("referenced table", identList(referenced.replace(/^public\./, "")).join(", "), expected.references); + check("referenced column", identList(referencedColumns).join(", "), expected.referencedColumn); + check("ON DELETE", actionFor("DELETE"), expected.onDelete); + check("ON UPDATE", actionFor("UPDATE"), expected.onUpdate); + return problems.length ? problems.join("; ") : null; +} + +/** + * The whole constraint verification, over an injected query so it is testable + * without a database. `query(sql, name)` must resolve to the rows the lookup + * returns — zero rows meaning "not on THIS table", which is a failure, never a + * pass. + */ +/** + * The column defaults the shape depends on, and which the upgrade path can + * silently leave wrong. + * + * A verify that reads column NAMES cannot see this: the column is present + * either way. `ReceiptIntake.state` created by an earlier Phase-1 revision + * defaults to 'RECEIVED', so every row inserted without an explicit state + * skipped STAGING and became claimable by the worker before its object + * existed. Pure, so the comparison is a unit test rather than a live DB. + */ +export const expectedColumnDefaults = { + ReceiptIntake: { state: "'STAGING'::text" }, +}; + +/** Does a `column_default` read from Postgres match what we require? */ +export function columnDefaultMatches(actual, expected) { + if (typeof actual !== "string") return false; + // Postgres renders a text literal default as `'STAGING'::text`; accept the + // bare literal too so the check does not depend on how it echoes casts. + const normalise = (v) => v.replace(/::[a-z ]+$/i, "").trim(); + return normalise(actual) === normalise(expected); +} + +export async function verifyColumnDefaults(query) { + const problems = []; + const notes = []; + for (const [table, columns] of Object.entries(expectedColumnDefaults)) { + for (const [column, expected] of Object.entries(columns)) { + const rows = await query( + `SELECT column_default FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 AND column_name=$2`, + table, + column, + ); + const actual = rows?.[0]?.column_default ?? null; + if (!columnDefaultMatches(actual, expected)) { + problems.push( + `${table}.${column} default is ${actual === null ? "(none)" : actual}, expected ${expected}`, + ); + } else { + notes.push(`verified default ${table}.${column} = ${expected}`); + } + } + } + return { problems, notes }; +} + +export async function verifyConstraints(query) { + const problems = []; + const notes = []; + for (const expected of expectedConstraints) { + const [row] = await query(CONSTRAINT_LOOKUP_SQL, expected.name); + if (!row) { + problems.push(`constraint ${expected.name} missing on ${expected.table}`); + continue; + } + if (expected.kind === "check") { + // Existence is not enough for the state CHECK: an OLD definition + // still exists, and it is the thing that breaks the cutover. + const missing = RECEIPT_INTAKE_STATES.filter(state => !row.def.includes(`'${state}'`)); + if (missing.length) { + problems.push(`${expected.name} does not allow: ${missing.join(", ")}\n actual: ${row.def}`); + continue; + } + notes.push(`verified ${expected.name}: all ${RECEIPT_INTAKE_STATES.length} states allowed`); + continue; + } + const drift = foreignKeyDrift(expected, row.def); + if (drift) { + problems.push(`${expected.name} has drifted: ${drift}\n actual: ${row.def}`); + continue; + } + notes.push(`verified ${expected.name}: ${row.def}`); + } + return { problems, notes }; +} + +// The partial index is the one object a "table exists" check cannot vouch for +// (Prisma would have created the table on its own; it would never create this). +// Verified on three properties, because any one of them alone can pass while +// the index is useless: it must EXIST, be UNIQUE (a non-unique index claims +// nothing, so every duplicate would sail through), and carry the EXACT +// predicate (a wider one quarantines rows that were deliberately excluded; a +// narrower one stops quarantining real duplicates). +const expectedPartialIndexes = [{ + name: "ReceiptIntake_dedupStrongKey_active_key", + mustMatch: [ + /CREATE UNIQUE INDEX/, + /\("dedupStrongKey"\)/, + /WHERE \(\("dedupStrongKey" IS NOT NULL\) AND \(state <> ALL \(ARRAY\['DUPLICATE'::text, 'VOID'::text\]\)\)\)/, + ], +}]; + + +// ── The receipts bucket ──────────────────────────────────────────────────── +// +// Provisioned HERE rather than by hand in the dashboard, because two of its +// settings are load-bearing and invisible from the application: +// +// * fileSizeLimit — the two-step upload goes straight to a signed URL that +// never passes through the server, so the BUCKET is the only place a 400 MB +// write can actually be refused. Application code can only reject the +// object afterwards, once the bytes are already stored and paid for. +// * allowedMimeTypes — same reason, for a format QuickBooks cannot attach. +// +// It is its own bucket, not `secure-docs`: those limits are per-bucket and +// cannot be imposed on contracts and invoice PDFs, and a signed upload URL is a +// write capability that must not point anywhere near the contract store. +// +// Idempotent: create if missing, otherwise VERIFY. A bucket that exists with +// the wrong limit is a hard failure — silently "fixing" a limit somebody set +// deliberately is how a 400 MB upload becomes possible again next quarter. +export const RECEIPT_BUCKET = "receipt-intake"; +// The SAME ceiling QuickBooks will attach at (MAX_STORED_BYTES / +// QBO_ATTACHMENT_MAX_BYTES in src/lib/receipt-intake/intake-core.ts, asserted +// equal by tests/apply-receipt-intake.test.ts). A bucket that accepts more than +// QBO can attach stores receipts that are guaranteed to strand. +export const RECEIPT_BUCKET_FILE_SIZE_LIMIT = 8 * 1024 * 1024; +// EXACTLY the list src/lib/receipt-intake/file-type.ts accepts (asserted by +// tests/apply-receipt-intake.test.ts). A bucket that allows more than the code +// does lets an unreadable file be stored; one that allows less rejects uploads +// the code promised were fine, at a signed URL where the caller sees only a +// storage error. +export const RECEIPT_BUCKET_MIME_TYPES = [ + "application/pdf", + "image/jpeg", + "image/png", + "image/heic", + "image/heif", + "image/webp", + "image/gif", +]; + +/** Normalizes Supabase's file_size_limit, which comes back as bytes or "15MB". */ +export function parseSizeLimit(value) { + if (value === null || value === undefined) return null; + if (typeof value === "number") return value; + const text = String(value).trim(); + if (/^\d+$/.test(text)) return Number(text); + const match = text.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i); + if (!match) return null; + const scale = { b: 1, kb: 1024, mb: 1024 * 1024, gb: 1024 * 1024 * 1024 }[match[2].toLowerCase()]; + return Math.round(Number(match[1]) * scale); +} + +async function storageRequest(baseUrl, key, path, init = {}) { + const res = await fetch(`${baseUrl.replace(/\/$/, "")}/storage/v1${path}`, { + ...init, + headers: { + authorization: `Bearer ${key}`, + apikey: key, + "content-type": "application/json", + ...(init.headers ?? {}), + }, + }); + const text = await res.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = { raw: text.slice(0, 200) }; + } + return { status: res.status, ok: res.ok, body }; +} + +/** + * SUPABASE REPORTS A MISSING BUCKET AS 400, NOT 404. + * + * Measured against production on 2026-09-04: GET /bucket/receipt-intake for a + * bucket that does not exist answers HTTP 400 with the body + * + * {"statusCode":"404","error":"Bucket not found", + * "message":"Bucket not found","code":"NoSuchBucket"} + * + * The outer status describes the REQUEST; Storage's own verdict is in the body. + * So a check reading only `res.status === 404` called the absent bucket an + * error and refused to create the very thing this step exists to create: + * + * Error: could not read bucket receipt-intake: 400 {"statusCode":"404",...} + * + * Every spelling of "absent" it is known to answer with, and NOTHING else. Any + * other non-2xx still throws, so a 500, a bad key or a permissions failure can + * never be read as "not there yet" and quietly answered by creating a bucket. + */ +export function bucketIsAbsent(response) { + if (!response || typeof response !== "object") return false; + if (response.ok) return false; + if (response.status === 404) return true; + const body = response.body; + if (!body || typeof body !== "object") return false; + if (body.statusCode === "404" || body.statusCode === 404) return true; + if (body.code === "NoSuchBucket") return true; + return body.error === "Bucket not found"; +} +/** + * Create or verify the bucket. Returns "created" | "verified"; THROWS when it + * exists with a different policy, because that is a fact the operator has to + * see rather than a state to overwrite. + */ +export async function ensureReceiptBucket(baseUrl, key, request = storageRequest) { + const existing = await request(baseUrl, key, `/bucket/${RECEIPT_BUCKET}`, { method: "GET" }); + + if (bucketIsAbsent(existing)) { + const created = await request(baseUrl, key, "/bucket", { + method: "POST", + body: JSON.stringify({ + id: RECEIPT_BUCKET, + name: RECEIPT_BUCKET, + public: false, + file_size_limit: RECEIPT_BUCKET_FILE_SIZE_LIMIT, + allowed_mime_types: RECEIPT_BUCKET_MIME_TYPES, + }), + }); + if (!created.ok) { + throw new Error(`could not create bucket ${RECEIPT_BUCKET}: ${created.status} ${JSON.stringify(created.body)}`); + } + return "created"; + } + if (!existing.ok) { + throw new Error(`could not read bucket ${RECEIPT_BUCKET}: ${existing.status} ${JSON.stringify(existing.body)}`); + } + + const bucket = existing.body ?? {}; + const problems = []; + if (bucket.public === true) problems.push("bucket is PUBLIC; receipts must be private"); + const limit = parseSizeLimit(bucket.file_size_limit); + if (limit !== RECEIPT_BUCKET_FILE_SIZE_LIMIT) { + problems.push(`file_size_limit is ${bucket.file_size_limit} (${limit} bytes), expected ${RECEIPT_BUCKET_FILE_SIZE_LIMIT}`); + } + const allowed = bucket.allowed_mime_types ?? null; + if (!Array.isArray(allowed)) { + problems.push("allowed_mime_types is unset; any file type could be uploaded"); + } else { + const missing = RECEIPT_BUCKET_MIME_TYPES.filter(m => !allowed.includes(m)); + const extra = allowed.filter(m => !RECEIPT_BUCKET_MIME_TYPES.includes(m)); + if (missing.length) problems.push(`allowed_mime_types is missing ${missing.join(", ")}`); + if (extra.length) problems.push(`allowed_mime_types carries unexpected ${extra.join(", ")}`); + } + if (problems.length) { + throw new Error(`bucket ${RECEIPT_BUCKET} exists with the wrong policy:\n - ${problems.join("\n - ")}`); + } + return "verified"; +} + +async function applyBucket() { + const baseUrl = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_KEY; + if (!baseUrl || !key) { + console.error("REFUSING: SUPABASE_URL and SUPABASE_SERVICE_KEY are required to provision the receipts bucket."); + console.error(" The bucket carries the 8 MiB and MIME limits that the signed-upload path cannot enforce anywhere else."); + process.exit(1); + } + const outcome = await ensureReceiptBucket(baseUrl, key); + console.log(`bucket ${RECEIPT_BUCKET}: ${outcome} (private, ${RECEIPT_BUCKET_FILE_SIZE_LIMIT} bytes, ${RECEIPT_BUCKET_MIME_TYPES.length} mime types)`); +} + +async function main() { + // EVERY flag is read here, inside main(), so the module stays inert on + // import -- see tests/apply-scripts-inert-on-import.test.ts. + const chosen = chooseTarget(process.argv); + if (!chosen.ok) { + console.error(chosen.reason); + process.exit(1); + } + const dryRun = process.argv.includes("--dry-run"); + if (!dryRun && !process.argv.includes("--yes")) { + console.error("Refusing to run without --yes (and --expect-db / --expect-host)."); + process.exit(1); + } + const expectDb = readFlagValue("--expect-db") ?? process.env.RECEIPT_INTAKE_EXPECT_DB; + const expectHost = readFlagValue("--expect-host") ?? process.env.RECEIPT_INTAKE_EXPECT_HOST; + if (!expectDb || !expectHost) { + console.error("Both --expect-db and --expect-host are required (or RECEIPT_INTAKE_EXPECT_DB / RECEIPT_INTAKE_EXPECT_HOST)."); + process.exit(1); + } + + const { url, from } = resolveTargetUrl(chosen.target); + console.log(`DATABASE_URL from ${from}: ${maskUrl(url)}`); + const prisma = new PrismaClient({ datasources: { db: { url } } }); + + try { + // WHO ARE WE TALKING TO -- asserted, then PRINTED, before any DDL. + const identity = await verifyProdIdentity( + (sql, ...args) => prisma.$queryRawUnsafe(sql, ...args), + hostOf(url), + projectRefOf(url), + process.env[PROJECT_REF_ENV], + chosen.target, + ); + console.log(identity.line); + if (identity.problems.length) { + for (const problem of identity.problems) console.error(`REFUSING: ${problem}`); + process.exit(1); + } + const actual = identity.actual; + console.log(`connected to db="${actual.db}" host="${actual.host}"`); + if (String(actual.db ?? "") !== String(expectDb ?? "")) { + console.error(`REFUSING: expected db="${expectDb}" but connected to db="${actual.db}".`); + process.exit(1); + } + // Behind the pooler it is the PROJECT'S database that answers, and its + // address is published under db..supabase.co, not under the pooler + // name. The ref is the one verifyProdIdentity already matched to + // APPLY_EXPECT_PROJECT_REF, so this names a stricter target, not a looser one. + const urlHostname = hostOf(url); + const directHost = directDbHostForUrl(url); + // The exact case first, so a target that needs no DNS never depends on it. + let hostMatch = targetMatches(actual, expectDb, expectHost) ? "exact" : null; + if (!hostMatch) { + try { + hostMatch = await targetHostMatches(actual.host, expectHost, lookupAddresses, urlHostname, directHost); + } catch (error) { + const unresolved = directHost ? `"${expectHost}" / "${directHost}"` : `"${expectHost}"`; + console.error( + `REFUSING: ${unresolved} could not be resolved (${error?.message ?? error}), so the connected ` + + `address "${actual.host || "(none -- Unix socket)"}" cannot be checked against it.`, + ); + process.exit(1); + } + } + if (!hostMatch) { + const names = directHost ? `"${expectHost}" or "${directHost}"` : `"${expectHost}"`; + console.error( + `REFUSING: expected db="${expectDb}" host="${expectHost}" but connected to db="${actual.db}" ` + + `host="${actual.host || "(none -- Unix socket)"}" -- that address is not ${names}, and neither name resolves to it.`, + ); + process.exit(1); + } + console.log( + `host check: ${hostMatch} -- --expect-host "${expectHost}" vs the address the server reported ` + + `("${actual.host || "(none -- Unix socket)"}")` + + (hostMatch === "project-db" ? `; behind the pooler the project's own database answers, so "${directHost}" was resolved too` : "") + + (hostMatch === "unix-socket" ? `; no address to compare, so the URL's own hostname "${urlHostname}" was used` : ""), + ); + + if (dryRun) { + console.log(`--dry-run: ${statements.length} statements would run against the target above.`); + for (const sql of statements) { + console.log(` ${sql.replace(/\s+/g, " ").slice(0, 84)}`); + } + return; + } + + for (const sql of statements) { + const label = sql.replace(/\s+/g, " ").slice(0, 84); + process.stdout.write(` ${label} ... `); + await prisma.$executeRawUnsafe(sql); + console.log("ok"); + } + + // Verify shape rather than trusting the run. + for (const [table, columns] of Object.entries(expectedColumns)) { + const rows = await prisma.$queryRawUnsafe( + `SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name=$1`, + table, + ); + const found = new Set(rows.map(r => r.column_name)); + const missing = columns.filter(c => !found.has(c)); + if (missing.length) { + console.error(`VERIFY FAILED: ${table} missing columns: ${missing.join(", ")}`); + process.exit(1); + } + console.log(`verified ${table}: ${columns.length} columns`); + } + // DEFAULTS, not just names: an upgraded table has every column and the + // wrong `state` default, which a name check reports as clean. + const defaults = await verifyColumnDefaults( + (sql, ...args) => prisma.$queryRawUnsafe(sql, ...args), + ); + for (const note of defaults.notes) console.log(note); + if (defaults.problems.length) { + for (const problem of defaults.problems) console.error(`VERIFY FAILED: ${problem}`); + process.exit(1); + } + + const constraints = await verifyConstraints( + (sql, name) => prisma.$queryRawUnsafe(sql, name), + ); + for (const note of constraints.notes) console.log(note); + if (constraints.problems.length) { + for (const problem of constraints.problems) console.error(`VERIFY FAILED: ${problem}`); + process.exit(1); + } + console.log(`verified ${expectedConstraints.length} constraints`); + + // indpred IS NOT NULL is the whole point: a plain unique index of the + // same name would silently quarantine nothing and reject legitimate + // re-reads, so assert the DEFINITION, not just the name. + for (const { name, mustMatch } of expectedPartialIndexes) { + const [row] = await prisma.$queryRawUnsafe( + `SELECT pg_get_indexdef(i.indexrelid) AS def + FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relnamespace = 'public'::regnamespace + AND i.indpred IS NOT NULL AND c.relname = $1`, + name, + ); + if (!row) { + console.error(`VERIFY FAILED: PARTIAL index ${name} missing (a non-partial index of that name is NOT the same thing)`); + process.exit(1); + } + for (const pattern of mustMatch) { + if (!pattern.test(row.def)) { + console.error(`VERIFY FAILED: ${name} does not match ${pattern}\n actual: ${row.def}`); + process.exit(1); + } + } + console.log(`verified partial index ${name}: ${row.def}`); + } + + // The bucket last: a failure here must not leave the table half-made, + // and the schema is useless without somewhere to put the bytes anyway. + // THE BUCKET IS PART OF THE PROD TARGET, not of the schema. It lives + // in Supabase, and `--target ci` runs against a throwaway Postgres + // container with no Supabase project behind it -- demanding a service + // key there would mean either failing every CI run or putting a real + // key in the workflow, and the whole point of that target is that it + // cannot reach a real project. The bucket's own policy is asserted + // separately by tests/apply-receipt-intake.test.ts, against the + // constants the code writes through. + if (chosen.target === "prod") { + await applyBucket(); + } else { + console.log(`bucket ${RECEIPT_BUCKET}: skipped (--target ${chosen.target} has no Supabase project)`); + } + + console.log("\nReceiptIntake migration applied and verified."); + } finally { + await prisma.$disconnect(); + } +} + +const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMainModule) { + main().catch(error => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/ci-apply-receipt-intake-e2e.mjs b/scripts/ci-apply-receipt-intake-e2e.mjs new file mode 100644 index 000000000..0d29cc4d9 --- /dev/null +++ b/scripts/ci-apply-receipt-intake-e2e.mjs @@ -0,0 +1,271 @@ +/** + * Drive scripts/apply-receipt-intake.mjs end to end against a throwaway + * database, the way production will run it. + * + * CI-only. `main()` is the one part of that script no other test executes: the + * unit tests exercise its exported helpers, and the DB-gated tests replay the + * `statements` array by hand. Neither of them proves the SCRIPT runs — that the + * flags parse, that the identity gate passes on a database it should pass on, + * that every statement executes in order against a real server, or that a + * second run is genuinely a no-op. + * + * Two shapes are built and upgraded, because they fail differently: + * + * 1. PRE-PHASE-1 — every committed migration except this feature's. The + * script has to create the whole table itself. + * 2. THE OLD PHASE-1 SHAPE — a ReceiptIntake created by an EARLIER revision + * of this script, with `state` defaulting to 'RECEIVED' and the later + * columns missing. `CREATE TABLE IF NOT EXISTS` is a no-op on it, so this + * is the only shape that exercises the additive upgrade section and the + * `ALTER COLUMN "state" SET DEFAULT 'STAGING'` repair. + * + * Both are then asserted to match what the committed migration produces. + */ +import { PrismaClient } from "@prisma/client"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, renameSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const SERVER = process.env.APPLY_E2E_SERVER_URL; +if (!SERVER) { + console.error("APPLY_E2E_SERVER_URL is required (a URL on the throwaway server)."); + process.exit(1); +} +if (/supabase\.(co|com)/i.test(SERVER)) { + console.error("REFUSING: APPLY_E2E_SERVER_URL looks like production."); + process.exit(1); +} + +const PHASE1_MIGRATION = "20260901000000_receipt_intake"; +const FRESH_DB = process.env.APPLY_E2E_DB ?? "probuild_apply_fresh"; +const UPGRADE_DB = `${FRESH_DB}_upgrade`; +const REFERENCE_DB = `${FRESH_DB}_reference`; + +const urlFor = db => { + const target = new URL(SERVER); + target.pathname = `/${db}`; + return target.toString(); +}; +const ADMIN = (() => { + const admin = new URL(SERVER); + admin.pathname = "/postgres"; + return admin.toString(); +})(); + +const run = (cmd, args, env) => + execFileSync(cmd, args, { + stdio: "inherit", + env: { ...process.env, ...env }, + shell: process.platform === "win32", + }); + +async function withClient(url, body) { + const client = new PrismaClient({ datasources: { db: { url } } }); + try { + return await body(client); + } finally { + await client.$disconnect(); + } +} + +async function recreate(...names) { + await withClient(ADMIN, async admin => { + for (const name of names) { + await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS "${name}"`); + await admin.$executeRawUnsafe(`CREATE DATABASE "${name}"`); + } + }); +} + +/** Deploy every committed migration EXCEPT this feature's. */ +function deployWithoutPhase1(url) { + const dir = path.join("prisma", "migrations", PHASE1_MIGRATION); + const parked = path.join(mkdtempSync(path.join(tmpdir(), "p1mig-")), PHASE1_MIGRATION); + renameSync(dir, parked); + try { + run("npx", ["prisma", "migrate", "deploy"], { DATABASE_URL: url, DIRECT_URL: url }); + } finally { + renameSync(parked, dir); + } +} + +/** + * The table as an EARLIER revision of this script left it: the old default, and + * without the columns later rounds added. Deliberately NOT generated from the + * committed statements — the point is a shape they have to upgrade. + */ +const OLD_PHASE1_TABLE = `CREATE TABLE "ReceiptIntake" ( + "id" TEXT NOT NULL, + "source" TEXT NOT NULL, + "sourceRef" TEXT NOT NULL, + "state" TEXT NOT NULL DEFAULT 'RECEIVED', + "dryRun" BOOLEAN NOT NULL DEFAULT true, + "stateReason" TEXT, + "projectId" TEXT, + "costCodeId" TEXT, + "suggestedCostCodeId" TEXT, + "suggestedConfidence" DOUBLE PRECISION, + "createdById" TEXT, + "storagePath" TEXT NOT NULL, + "fileName" TEXT, + "mimeType" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "fileSha256" TEXT NOT NULL, + "vendor" TEXT, + "txnDate" DATE, + "totalCents" INTEGER, + "taxCents" INTEGER, + "docType" TEXT, + "refNumber" TEXT, + "memo" TEXT, + "readJson" TEXT, + "readAt" TIMESTAMP(3), + "dedupStrongKey" TEXT, + "dedupWeakKey" TEXT, + "duplicateOfId" TEXT, + "qbPurchaseId" TEXT, + "expenseId" TEXT, + "archiveDriveFileId" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextRetryAt" TIMESTAMP(3), + "bookedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReceiptIntake_pkey" PRIMARY KEY ("id") +)`; + +const SCRIPT = path.join("scripts", "apply-receipt-intake.mjs"); + +/** Run the real script against `db`, twice, and prove the second run is a no-op. */ +async function applyTwice(db, label) { + const url = urlFor(db); + const host = await withClient(url, async client => { + const [row] = await client.$queryRawUnsafe( + `SELECT COALESCE(host(inet_server_addr()), '') AS host`, + ); + return row.host; + }); + // `--target ci`: the ambient URL, no production baseline row and no project + // ref, and the script REFUSES outright if that URL looks like Supabase. The + // production guard cannot be satisfied through this path even by accident. + const guard = ["--target", "ci", "--yes", "--expect-db", db, "--expect-host", host]; + console.log(`\n=== ${label}: apply ===`); + run("node", [SCRIPT, ...guard], { DATABASE_URL: url }); + // Idempotency is the property the deploy note rests on: it is safe to run + // again after a partial failure, or twice by mistake. + console.log(`\n=== ${label}: apply again (idempotency) ===`); + run("node", [SCRIPT, ...guard], { DATABASE_URL: url }); +} + +/** + * The shape, as facts a comparison can fail on: columns with their types and + * defaults, plus index and constraint names. + */ +async function shapeOf(db) { + return withClient(urlFor(db), async client => { + const columns = await client.$queryRawUnsafe( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema='public' AND table_name='ReceiptIntake' + ORDER BY column_name`, + ); + const indexes = await client.$queryRawUnsafe( + `SELECT indexname, indexdef FROM pg_indexes + WHERE schemaname='public' AND tablename='ReceiptIntake' + ORDER BY indexname`, + ); + const constraints = await client.$queryRawUnsafe( + `SELECT conname, pg_get_constraintdef(oid) AS def + FROM pg_constraint + WHERE conrelid = '"ReceiptIntake"'::regclass + ORDER BY conname`, + ); + return { columns, indexes, constraints }; + }); +} + +function assertSame(label, actual, expected) { + const a = JSON.stringify(actual, null, 1); + const b = JSON.stringify(expected, null, 1); + if (a === b) { + console.log(` ${label}: matches the committed migration`); + return; + } + console.error(`MISMATCH in ${label}`); + console.error("--- the script produced ---"); + console.error(a); + console.error("--- the migration produces ---"); + console.error(b); + process.exit(1); +} + +await recreate(FRESH_DB, UPGRADE_DB, REFERENCE_DB); + +// The yardstick: every committed migration, including this feature's. +console.log("\n=== reference: prisma migrate deploy ==="); +run("npx", ["prisma", "migrate", "deploy"], { + DATABASE_URL: urlFor(REFERENCE_DB), + DIRECT_URL: urlFor(REFERENCE_DB), +}); +const reference = await shapeOf(REFERENCE_DB); + +// 1. FROM SCRATCH: pre-Phase-1, then the script creates the table itself. +deployWithoutPhase1(urlFor(FRESH_DB)); +await applyTwice(FRESH_DB, "from scratch"); + +// 2. THE UPGRADE: pre-Phase-1 PLUS a table an earlier revision of this script +// left behind, with the old default. This is the only path that exercises +// the additive section and the state-default repair. +deployWithoutPhase1(urlFor(UPGRADE_DB)); +await withClient(urlFor(UPGRADE_DB), async client => { + await client.$executeRawUnsafe(OLD_PHASE1_TABLE); + const [before] = await client.$queryRawUnsafe( + `SELECT column_default FROM information_schema.columns + WHERE table_schema='public' AND table_name='ReceiptIntake' AND column_name='state'`, + ); + if (!String(before?.column_default ?? "").includes("RECEIVED")) { + console.error("the drifted fixture did not take: expected DEFAULT 'RECEIVED'"); + process.exit(1); + } + console.log(`\ndrifted fixture in place: state default = ${before.column_default}`); +}); +await applyTwice(UPGRADE_DB, "upgrade from the old shape"); + +await withClient(urlFor(UPGRADE_DB), async client => { + const [after] = await client.$queryRawUnsafe( + `SELECT column_default FROM information_schema.columns + WHERE table_schema='public' AND table_name='ReceiptIntake' AND column_name='state'`, + ); + if (!String(after?.column_default ?? "").includes("STAGING")) { + console.error(`the state default was NOT repaired: ${after?.column_default}`); + process.exit(1); + } + console.log(`state default repaired: ${after.column_default}`); + // ...and it is a real default, not just a catalogue entry. + await client.$executeRawUnsafe( + `INSERT INTO "ReceiptIntake" + ("id", "source", "sourceRef", "storagePath", "mimeType", "fileSize", "fileSha256", "updatedAt") + VALUES ('ci-probe', 'drive', 'drive:ci-probe', 'receipts/intake/ci-probe.png', + 'image/png', 4, 'b', NOW())`, + ); + const [row] = await client.$queryRawUnsafe( + `SELECT state FROM "ReceiptIntake" WHERE id='ci-probe'`, + ); + if (row?.state !== "STAGING") { + console.error(`a row inserted with no state landed in ${row?.state}, not STAGING`); + process.exit(1); + } + await client.$executeRawUnsafe(`DELETE FROM "ReceiptIntake" WHERE id='ci-probe'`); +}); + +console.log("\n=== comparing shapes against the committed migration ==="); +for (const [db, label] of [[FRESH_DB, "from scratch"], [UPGRADE_DB, "upgrade"]]) { + const shape = await shapeOf(db); + assertSame(`${label}: columns`, shape.columns, reference.columns); + assertSame(`${label}: indexes`, shape.indexes, reference.indexes); + assertSame(`${label}: constraints`, shape.constraints, reference.constraints); +} + +console.log("\napply script end-to-end: OK"); diff --git a/src/app/api/automation/ai-review/route.ts b/src/app/api/automation/ai-review/route.ts index dd02d0cf2..8447e7dde 100644 --- a/src/app/api/automation/ai-review/route.ts +++ b/src/app/api/automation/ai-review/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { isReceiptUrlRef, resolveReceiptUrl } from "@/lib/receipt-intake/receipt-url"; import Anthropic from "@anthropic-ai/sdk"; import { GoogleGenAI } from "@google/genai"; import { getCurrentUserWithPermissions, hasPermission } from "@/lib/permissions"; @@ -599,11 +600,24 @@ export async function POST(request: Request) { return NextResponse.json({ ok: false, reason: "no-stored-copy" }); } + // A receipt this pipeline booked is a STORED REFERENCE, not a link — it is + // resolved to a short-lived signed URL here rather than being fetched as + // written. Everything else keeps the old contract exactly. + const receiptUrl = isReceiptUrlRef(expense.receiptUrl) + ? await resolveReceiptUrl(expense.receiptUrl) + : expense.receiptUrl; + if (!receiptUrl) { + return NextResponse.json({ ok: false, reason: "no-stored-copy" }); + } + // SSRF sink check: receiptUrl is written only by our sync/upload code, // but this fetch enforces the invariant anyway — the URL must point at - // OUR Supabase public storage, no redirects followed. - const storagePrefix = `${(process.env.SUPABASE_URL ?? "").replace(/\/$/, "")}/storage/v1/object/public/`; - if (!process.env.SUPABASE_URL || !expense.receiptUrl.startsWith(storagePrefix)) { + // OUR Supabase storage, no redirects followed. Public objects carry the + // `/public/` segment; a signed one carries `/sign/`, so both prefixes are + // named explicitly rather than loosened to the storage root. + const storageRoot = `${(process.env.SUPABASE_URL ?? "").replace(/\/$/, "")}/storage/v1/object/`; + const allowed = [`${storageRoot}public/`, `${storageRoot}sign/`]; + if (!process.env.SUPABASE_URL || !allowed.some(prefix => receiptUrl.startsWith(prefix))) { console.error("ai-review refused non-storage receiptUrl"); return NextResponse.json({ ok: false, reason: "receipt-url-untrusted" }, { status: 409 }); } @@ -620,7 +634,7 @@ export async function POST(request: Request) { inFlightDocs.add(dedupeKey); try { - const fileRes = await fetch(expense.receiptUrl, { redirect: "error", signal: AbortSignal.timeout(20_000) }); + const fileRes = await fetch(receiptUrl, { redirect: "error", signal: AbortSignal.timeout(20_000) }); if (!fileRes.ok) { return NextResponse.json({ ok: false, reason: "receipt-fetch-failed" }, { status: 502 }); } diff --git a/src/app/api/cron/receipt-intake-worker/route.ts b/src/app/api/cron/receipt-intake-worker/route.ts new file mode 100644 index 000000000..969b2daea --- /dev/null +++ b/src/app/api/cron/receipt-intake-worker/route.ts @@ -0,0 +1,1082 @@ +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { isCronAuthorized } from "@/lib/cron-auth"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { isPaused, PAUSE_KEYS } from "@/lib/automation-settings"; +import { acquireCronLease } from "@/lib/cron-lease"; +import { logAutomationEvent } from "@/lib/automation-events"; +import { + downloadVerified, + inspectStoredObject, + // THE one builder for a lease-bearing CAS. See leaseFence. + leaseFence, + sealAndPublish, +} from "@/lib/receipt-intake/stored-object"; +import { + deleteObjectOrRecord, + queueObjectCleanup, + claimObjectPath, + resolveCanonicalIntent, + rejectRowAndQueueCleanup, + liveSweepDepsFor, + retryPendingCleanups, + sealObject, + settleQueuedCleanup, + inShortTx, +} from "@/lib/receipt-intake/storage-cleanup"; +import { getFreshQBTokens } from "@/lib/quickbooks-payments"; +import { createQBReceiptPurchase } from "@/lib/qbo-receipt-push"; +import { createRouteDeadline, remainingBudgetMs, type RouteDeadline } from "@/lib/quickbooks"; +import { readReceipt } from "@/lib/receipt-intake/read"; +import { canonicalVendor } from "@/lib/receipt-intake/keys"; +import { + applyCutoverVerdict, + driveFileIdOf, + triageCutoverRows, resolveCutoverBoundary, type CutoverRow } from "@/lib/receipt-intake/cutover"; +import { resolveCompanyTimeZone } from "@/lib/company-timezone"; +import { isCostCodeAllowedForProject, resolveProjectPhaseCodes } from "@/lib/project-phases"; +import { prismaPhaseDataSource } from "@/lib/project-phases-db"; +import { bookReceipt, type BookPrismaClient } from "@/lib/receipt-intake/book"; +import { backoffMs } from "@/lib/receipt-intake/route-state"; +import { + BATCH_SIZE, + CLAIM_LEASE_MINUTES, + CLAIM_LOCK_KEY, + cleanupNotBefore, + RUN_HARD_BUDGET_MS, + STAGING_SWEEP_BATCH, + STAGING_SWEEP_MINUTES, + type ClaimResult, + type CutoverRequest, + eligibleClaimWhere, + isUniqueViolation, + readBudgetFor, + runIntakeWorker, + uploadLeaseActive, + type ReadPatch, + type WorkerDependencies, + type WorkerRow, +} from "@/lib/receipt-intake/worker"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +/** + * Receipt Pipeline v2 worker (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §5). + * Every 5 minutes: claim at most 10 due rows, read/dedup/route the new ones, + * and book the ones that are cleared to book. + * + * OVERLAP SAFETY, in three layers — and it is worth being exact about what + * each one actually buys, because the first two were once described as though + * they did the third one's job: + * + * 1. A DURABLE INVOCATION LEASE (lib/cron-lease.ts), taken before anything is + * read, claimed or booked and released in a `finally`. THIS is what makes + * the worker non-overlapping. Its TTL outlives `maxDuration`, so the + * platform kills a pass before its lease can lapse. + * 2. `pg_try_advisory_xact_lock` around the CLAIM TRANSACTION. It is + * transaction scoped and is gone the moment that transaction commits — it + * makes the cutover triage and the claim atomic with respect to each + * other, and NOTHING about the Gemini read and QuickBooks write that + * follow. It is retained because a lease that expires under a still-live + * pass (or a stray manual invocation) must still not corrupt a claim. + * 3. Per-row ownership. The claim bumps every taken row's `nextRetryAt` and + * stamps a `claimToken` that every completing write is fenced on, so even + * two interleaved passes never hand the same row to two workers — and + * QBO's DocNumber/requestid idempotency means a double booking creates + * one Purchase, not two. + * + * pgbouncer is why (2) cannot simply be a SESSION advisory lock: a pooled + * connection is not the same connection twice (see review-alert-rollout.ts:8). + * + * Auth is isCronAuthorized() from lib/cron-auth: constant-time Bearer compare, + * required EVERYWHERE except an explicit NODE_ENV === "development", and a + * missing CRON_SECRET rejects rather than waving traffic through. + */ + +const LEASE_MS = CLAIM_LEASE_MINUTES * 60_000; + +const WORKER_ROW_SELECT = { + id: true, source: true, sourceRef: true, state: true, dryRun: true, + projectId: true, costCodeId: true, suggestedCostCodeId: true, + storagePath: true, fileName: true, mimeType: true, fileSize: true, + vendor: true, txnDate: true, totalCents: true, taxCents: true, + docType: true, refNumber: true, memo: true, attempts: true, readAt: true, lastError: true, + suggestedConfidence: true, sendAttempted: true, claimToken: true, fileSha256: true, + createdAt: true, dedupWeakKey: true, busyPasses: true, stateReason: true, + taxWarning: true, +} as const; + +/** + * RELEASING OWNERSHIP is part of the transition, not a follow-up write. + * + * A claim is what makes a row invisible to the next pass. Every write that + * COMPLETES, DEFERS or PARKS the work must hand it back in the same update, or + * the row stays owned by a pass that has finished: the claim query skips it, + * every fenced write misses it, and it sits until a human notices. The only + * transitions that keep it are READ -> BOOKING (the same pass books it, under + * the same token) and abandonment at the soft deadline, where the pass really + * is still holding the row. + */ +const RELEASE_CLAIM = { claimToken: null, claimedAt: null } as const; + +/** + * How long the invocation lease is held for. + * + * Longer than the route's own `maxDuration = 60`, deliberately: a lease that + * could expire while its pass was still running would let a second invocation + * in on exactly the run it exists to exclude, and the only alternative is + * heartbeating from inside a loop that spends its time blocked on Gemini and + * QuickBooks. The platform kills the pass first, and the next cron is five + * minutes out, so a crashed invocation's lease is always stale before anyone + * needs it. + */ +const WORKER_LEASE_MS = 90_000; +const WORKER_LEASE_KEY = "receiptIntakeWorkerLease"; + +async function claim(opts: CutoverRequest): Promise { + const now = new Date(); + return prisma.$transaction(async tx => { + const [lock] = await tx.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${CLAIM_LOCK_KEY}, 0)) AS locked`, + ); + if (!lock?.locked) return null; + + // CUTOVER, inside the lock and the same transaction as the claim. + // + // Everything received during the shadow week was booked by v1, so v2 + // RETIRES it — SHADOW_DONE, terminal, never booked here. It is not + // requeued, because v2's QBO identity for an email/chat/mobile/web row + // is the intake UUID, which v1 never saw: QuickBooks' DocNumber + // idempotency could not have recognised the Purchase v1 already made, + // and the entire backlog would have booked a second time on real books + // in a single pass. (Drive rows book under the Drive file id — v1's own + // identity — so those alone would have been safe. That is not enough to + // requeue the rest.) + // + // The rows keep their read results and dedup keys, so a post-cutover + // resend of the same receipt still collides with them and is caught. + let shadowRetired = 0; + let shadowQuarantined = 0; + let requeued = 0; + /** Rows that moved between the select and the write, so no verdict landed. */ + let shadowSkippedMoved = 0; + if (!opts.dryRunGlobal) { + // runIntakeWorker halts before ever calling claim() without one, so + // this is belt-and-braces rather than the real gate. + if (!opts.boundary) { + console.error("[cron/receipt-intake-worker] claim() reached with no boundary — refusing"); + } else { + const parked: Prisma.ReceiptIntakeWhereInput = { + dryRun: true, + state: { in: ["READ", "BOOKING"] }, + }; + + // RETIREMENT NEEDS POSITIVE EVIDENCE, not just an old timestamp. + // + // "Received before the boundary" says when the file ARRIVED, not + // that anything booked it. v1 skips documents constantly — a bad + // read, a park, a file it never picked up — and every one of + // those would have been retired as "booked-by-v1" and silently + // dropped. So a row is only retired when we can point at the + // booking: + // + // * an AutomationEvent from v1's own push (it goes through + // ProBuild's create route, which logs kind receipt-push with + // status created/already-exists and the Drive fileId), or + // * the forwarder telling us it archived the file + // (archivedByV1, set from the forward payload). + // + // Everything else is handed to v2. That is safe for the Drive + // rows this applies to: they book under the DRIVE FILE ID, so + // QBO's DocNumber/requestid idempotency collapses a v1/v2 + // overlap into one Purchase. + // EVERY parked row, not just the ones older than the + // boundary. Evidence outranks the timestamp: the forwarder can + // hand over a file v1 had ALREADY booked minutes after the + // flip (a queued send, a retry, a slow archive step), and + // filtering by createdAt first meant those rows never reached + // the evidence check at all — they went straight into the + // requeue and v2 booked a second Purchase for an email or chat + // receipt, where there is no shared identity to collapse it. + // The boundary is only used to decide what to do with rows that + // have NO evidence either way. + const candidates = await tx.receiptIntake.findMany({ + where: parked, + select: { + id: true, source: true, sourceRef: true, + archivedByV1: true, createdAt: true, + // The evidence each write fences on, read here so the + // verdict and the row it was reached about travel + // together instead of the write re-deriving a predicate. + // claimToken is PINNED at what was observed rather than + // required null — see CutoverRow for why demanding null + // would hide a stale-claimed row from the cutover for + // good. + state: true, stateReason: true, dryRun: true, claimToken: true, + }, + }); + + const driveIds = candidates + .map(driveFileIdOf) + .filter((v): v is string => !!v); + + const bookedByV1 = driveIds.length + ? new Set( + (await tx.automationEvent.findMany({ + where: { + kind: "receipt-push", + status: { in: ["created", "already-exists"] }, + driveFileId: { in: driveIds }, + }, + select: { driveFileId: true }, + })).map(e => e.driveFileId).filter((v): v is string => !!v), + ) + : new Set(); + + // Three outcomes, not two. The middle one is the honest answer + // to a question we cannot settle from data: + // + // evidenced -> v1 booked it. Retire. + // no evidence, + // DRIVE row -> hand to v2. Safe BECAUSE it books under + // the Drive file id, so if v1 did book it + // after all, QBO's DocNumber/requestid + // idempotency collapses the two into one + // Purchase. + // no evidence, + // NOT a Drive -> quarantine. There is no shared identity + // row here: v2 would book under the intake + // UUID, which v1 never saw, so a duplicate + // would go through silently. Booking risks + // double-paying; retiring risks losing a + // real expense. A human checks QBO and uses + // "book anyway". + // ONE implementation of the three-way split, in the lib, so + // it is testable without standing up a cron route. + const { evidenced, unevidenced, quarantined } = + triageCutoverRows(candidates, opts.boundary, bookedByV1); + + // EVERY cutover write is a CAS over the row the verdict was + // reached about — see applyCutoverVerdict. Constraining only + // `id` let a concurrent transition (an admin review, a late + // completion) be overwritten with a terminal SHADOW_* state or + // silently handed to v2, in a transaction that had read the row + // before any of that happened. + const byId = new Map(candidates.map(row => [row.id, row])); + const rowsFor = (ids: string[]) => + ids.map(id => byId.get(id)).filter((row): row is CutoverRow => !!row); + + if (evidenced.length) { + const retired = await applyCutoverVerdict( + rowsFor(evidenced), + { state: "SHADOW_DONE", stateReason: "booked-by-v1", nextRetryAt: null }, + tx.receiptIntake, + ); + shadowRetired = retired.moved; + shadowSkippedMoved += retired.skippedMoved; + } + + if (quarantined.length) { + const held = await applyCutoverVerdict( + rowsFor(quarantined), + { + state: "SHADOW_QUARANTINE", + stateReason: "no-v1-evidence", + // Terminal: it must never come back round on a + // retry timer. Only a human moves it. + nextRetryAt: null, + dryRun: false, + }, + tx.receiptIntake, + ); + shadowQuarantined = held.moved; + shadowSkippedMoved += held.skippedMoved; + } + + // Everything else is v2's to book. The list is built row by + // row above rather than re-derived from a createdAt predicate + // here, so the two can never disagree about which rows the + // evidence check already claimed. + if (unevidenced.length) { + const handed = await applyCutoverVerdict( + rowsFor(unevidenced), + { dryRun: false, nextRetryAt: null }, + tx.receiptIntake, + ); + requeued = handed.moved; + shadowSkippedMoved += handed.skippedMoved; + } + + if (shadowRetired > 0 || requeued > 0 || shadowQuarantined > 0 || shadowSkippedMoved > 0) { + console.log("[cron/receipt-intake-worker] cutover", JSON.stringify({ + boundary: opts.boundary.toISOString(), + shadowRetired, requeued, shadowQuarantined, shadowSkippedMoved, + })); + } + } + } + + // ONE predicate, shared with the worker lib and with the real-Postgres + // claim test, and a FUNCTION of the current global switch — see + // eligibleClaimWhere. A second copy of it here is how the claim and the + // processing loop came to disagree about which rows were workable. + const ELIGIBLE = eligibleClaimWhere(now, opts.dryRunGlobal); + + const due = await tx.receiptIntake.findMany({ + where: ELIGIBLE, + orderBy: { createdAt: "asc" }, + take: BATCH_SIZE, + select: { id: true }, + }); + if (due.length === 0) return { rows: [], shadowRetired, requeued, shadowQuarantined, shadowSkippedMoved }; + + // THE claim is ATOMIC with the select it followed: the UPDATE re-checks + // the SAME eligibility predicate rather than blindly writing every id + // the SELECT returned. Between those two statements — even inside this + // one transaction, under READ COMMITTED — another writer with no reason + // to touch the advisory lock (a late `retryRow`, a `deferRead`, an admin + // action) can still move a row's `nextRetryAt` into the future or its + // state off the eligible list. Claiming by id alone would stomp that + // write and hand the row to this pass anyway; re-checking the predicate + // here means such a row is left untouched instead. + const ids = due.map(r => r.id); + const claimToken = randomUUID(); + const claimed = await tx.receiptIntake.updateMany({ + where: { id: { in: ids }, ...ELIGIBLE }, + data: { nextRetryAt: new Date(now.getTime() + LEASE_MS), claimToken, claimedAt: now }, + }); + if (claimed.count === 0) return { rows: [], shadowRetired, requeued, shadowQuarantined, shadowSkippedMoved }; + + // Re-read by id AND the fresh token — never by the original id list — + // so only the rows this UPDATE actually touched are handed to the pass. + // A row the predicate above skipped keeps its OLD claimToken and is + // invisible here even though its id is still in `ids`. + const rows = await tx.receiptIntake.findMany({ + where: { id: { in: ids }, claimToken }, + select: WORKER_ROW_SELECT, + }); + return { + rows: rows as WorkerRow[], + shadowRetired, + requeued, + shadowQuarantined, + shadowSkippedMoved, + }; + }); +} + +function buildDeps(invocationDeadline: RouteDeadline): WorkerDependencies { + return { + acquireLease: () => acquireCronLease(WORKER_LEASE_KEY, WORKER_LEASE_MS), + + claim, + + isDryRunEnabled: () => process.env.RECEIPT_INTAKE_DRYRUN !== "false", + + cutoverBoundary: resolveCutoverBoundary, + + sweepStaleStaging: async shouldStop => { + // NEVER a blanket "old therefore missing". A STAGING row that is old + // because its publish UPDATE failed HAS its object in the bucket, + // and declaring that receipt file-missing would hand a human a + // problem that does not exist while the real file sits there. Ask + // storage about each one, and let a transient storage fault mean + // "come back next pass" rather than either verdict. + const sweptAt = new Date(); + const cutoff = new Date(sweptAt.getTime() - STAGING_SWEEP_MINUTES * 60_000); + const stale = await prisma.receiptIntake.findMany({ + // A LIVE LEASE IS NOT SWEEPABLE, and it must not occupy one of + // the ten slots either. Selecting it and skipping it inside the + // loop meant a handful of clients still uploading could fill the + // whole batch every pass, so the orphans behind them were never + // reached — the queue looked busy and cleared nothing. + where: { + state: "STAGING", + createdAt: { lt: cutoff }, + OR: [ + { uploadUrlExpiresAt: null }, + { uploadUrlExpiresAt: { lte: sweptAt } }, + ], + }, + select: { + // `state` is read rather than assumed from the WHERE above: + // every mutation below fences on the row as OBSERVED, and a + // hard-coded "STAGING" in the fence would be a second copy + // of that fact that could drift from the query. + id: true, state: true, storagePath: true, mimeType: true, stateReason: true, + createdAt: true, expectedSha256: true, uploadUrlExpiresAt: true, + uploadLeaseVersion: true, uploadLeaseNonce: true, + }, + // Rows that never had a signed URL first (an inline upload that + // died mid-request is an orphan NOW, and nothing is coming for + // it), then oldest-first among the expired leases. + orderBy: [ + { uploadUrlExpiresAt: { sort: "asc", nulls: "first" } }, + { createdAt: "asc" }, + ], + // Small on purpose: each row costs a storage round trip, and the + // sweep runs BEFORE any receipt is processed. A big batch here + // spends the invocation on housekeeping. + take: STAGING_SWEEP_BATCH, + }); + + let published = 0; + let parked = 0; + let rejected = 0; + let leaseActive = 0; + for (const row of stale) { + // The sweep is inside the run's deadline, not outside it. + if (shouldStop()) break; + + // NOTHING DESTRUCTIVE WHILE THE UPLOAD LEASE IS LIVE. + // + // `uploadUrlExpiresAt` is the promise /start made to this + // client. Until it passes, whatever is (or is not) at the path + // is provisional: an empty path is an upload in flight, and a + // half-written or superseded object is one the client is about + // to replace. Publishing is still allowed — a complete, correct + // object is a complete, correct object — but parking it as + // file-missing, or DELETING it as unacceptable, would destroy a + // receipt whose own upload link is still working. + // Belt and braces: the query already excluded live leases, but + // one can be re-armed between that SELECT and this check. + const leaseLive = uploadLeaseActive(row); + + // THE SAME validator /finalize uses. Publishing on "the object + // exists" alone would wave through a 40 MB video, an executable, + // or a truncated upload that /finalize would have refused — and + // those rows then go to Gemini and, if they read at all, to + // QuickBooks. One implementation, so the two cannot diverge. + // The INVOCATION's deadline, not a fresh allowance per call: a + // pass that has already spent 50 of its 60 seconds must not + // hand the next storage call a full fifteen. + const check = await inspectStoredObject( + row.storagePath, + row.mimeType, + invocationDeadline, + ); + + if (check.ok) { + // The bytes that landed must be the document /start was told + // about. Otherwise the sweep would publish whatever happened + // to be at that path — which is the same overwrite the seal + // exists to close, arriving by a different door. + if (row.expectedSha256 && row.expectedSha256 !== check.fileSha256) { + // RECOVERABLE, not a park. A partial or superseded + // upload sitting at the path while the signed URL is + // still valid is exactly the state a client is about to + // fix by finishing its upload. Parking it here would + // turn a retry-in-progress into a review item, and the + // correct bytes arriving a minute later would find the + // row already gone from STAGING. + if (leaseLive) { leaseActive++; continue; } + // THE COMPLETE LEASE IDENTITY the verdict was reached + // about — not state + version. + // + // `leaseLive` was computed from the SELECT at the top + // of this sweep, and everything since (a storage round + // trip per row) is time in which a /start retry can + // extend the lease over the same path at the same + // version, moving only the nonce and the expiry. A + // fence of {state, version} still matched, so the sweep + // parked a row whose upload URL had just been renewed + // and whose client was still uploading to it. The + // reject branch below already pinned the whole identity; + // this is the same rule, applied to the writes that + // forgot it. + const { count: mismatchParked } = await prisma.receiptIntake.updateMany({ + where: { id: row.id, ...leaseFence(row) }, + data: { state: "NEEDS_REVIEW", stateReason: "sha-mismatch", nextRetryAt: null }, + }); + // COUNTED ONLY WHEN THE CAS LANDED. A losing write + // reported a park that never happened, so the sweep's + // own log said it had cleared rows it had not touched. + if (mismatchParked > 0) parked++; + else leaseActive++; + continue; + } + + // The SAME seal-and-publish /finalize uses. The sweep must + // never publish a row still pointing at the UPLOAD path: + // that path stays writable by whoever holds the signed URL, + // so a swept row's "verified" bytes would remain replaceable + // afterwards. + const outcome = await sealAndPublish(row.storagePath, row.id, row.uploadLeaseVersion, check, { + inShortTx, + seal: sealObject, + commit: async (tx, canonicalPath, values) => { + const { count } = await tx.receiptIntake.updateMany({ + // Same complete identity as the parks. This one + // publishes rather than parks, but a lease + // refreshed since the inspection means the + // client is mid-upload of something else — and + // publishing that row would seal bytes it is + // about to replace, then schedule the upload + // path's cleanup against an expiry the live URL + // outlives. The schedule below reads the SAME + // snapshot this CAS pins, so the two agree by + // construction. + where: { id: row.id, ...leaseFence(row) }, + data: { + state: "RECEIVED", + nextRetryAt: null, + storagePath: canonicalPath, + mimeType: values.mimeType, + fileSize: values.fileSize, + fileSha256: values.fileSha256, + }, + }); + return count; + }, + // PUBLISHING is allowed while the lease is live (a + // complete, correct object is one whether or not the + // URL has expired), so unlike the park and reject + // branches below this one CAN run with a live upload + // URL — and the upload path's delete has to wait for + // it, or the holder's late PUT recreates an object the + // published row no longer points at. + // + // Enqueued INSIDE the commit transaction, same rule as + // /finalize: the queue entry outlives the pointer, so + // the two have to commit together. + queueUploadCleanup: (tx, uploadPath) => + queueObjectCleanup(tx, uploadPath, "sealed", cleanupNotBefore(row)), + // PHASE A, same as the /finalize publisher: the seal is + // an external write ahead of the CAS either way, so the + // path is claimed with a lease before anything is + // written and the lease is what keeps this sweep's own + // cleanup pass off it in the meantime. + claimCanonicalPath: canonicalPath => + claimObjectPath(canonicalPath, cleanupNotBefore(row)), + resolveCanonicalIntent, + settleUploadCleanup: (eventId, uploadPath) => + settleQueuedCleanup(eventId, uploadPath, cleanupNotBefore(row)) + .then(() => undefined), + }, invocationDeadline); + if (outcome?.published) published++; + continue; + } + if (check.kind === "transient") continue; // unknown is not a verdict + if (check.kind === "missing") { + // The signed upload URL is good for two hours. Parking at 15 + // minutes declared a receipt missing while its own upload + // link was still perfectly usable — a slow phone on a bad + // connection came back to find its row already in the review + // queue. Wait until the URL cannot possibly land any more. + if (leaseLive) { leaseActive++; continue; } + // The complete identity again: `leaseLive` is a fact about + // the SELECT, and a /start retry between it and here + // renews the very URL this park says can no longer land. + const { count: missingParked } = await prisma.receiptIntake.updateMany({ + where: { id: row.id, ...leaseFence(row) }, + data: { state: "NEEDS_REVIEW", stateReason: "file-missing", nextRetryAt: null }, + }); + if (missingParked > 0) parked++; + else leaseActive++; + continue; + } + // Rejected: the object exists and is not acceptable. + // + // Not while the lease is live: what is at the path may be a + // partial write the client is still finishing, and deleting the + // row destroys the only record of an inbound receipt. + if (leaseLive) { leaseActive++; continue; } + + // The SAME fenced transaction /finalize rejects with: the row + // and its cleanup record commit together, under the exact state + // and path we just inspected. The unfenced delete this replaces + // could destroy a row a concurrent /finalize had just published + // — and then delete the bytes that published row pointed at. + const dropped = await rejectRowAndQueueCleanup( + { + id: row.id, + // The OBSERVED state, like every other field here — a + // literal would be a second copy of the SELECT's own + // predicate, free to drift from it. + state: row.state, + stateReason: row.stateReason, + storagePath: row.storagePath, + uploadLeaseVersion: row.uploadLeaseVersion, + // The generation too: the version alone cannot see a + // same-path lease refresh (see leaseFence). The + // `verify` callback below still re-reads the row, and + // the two are complementary — this one fails the CAS, + // that one aborts the transaction. + uploadLeaseNonce: row.uploadLeaseNonce, + uploadUrlExpiresAt: row.uploadUrlExpiresAt, + // Always null here — `leaseLive` above already refused + // to reject a row whose URL still works. Passed anyway + // so both rejecters state the rule the same way rather + // than one of them relying on a guard several lines up. + cleanupNotBefore: cleanupNotBefore(row), + }, + check.reason, + undefined, + // DECIDED ON A ROW RE-READ INSIDE THE TRANSACTION. + // + // Between the inspection above and this delete a client can + // resume its upload: /start bumps the lease and hands out a + // fresh URL. The fence catches the version, and this catches + // the case the fence cannot see — a lease that is live again + // — so a receipt in flight is never deleted for what its + // previous attempt left at the path. + fresh => uploadLeaseActive(fresh as { uploadUrlExpiresAt: Date | null; createdAt: Date }) + ? "upload-lease-active" + : null, + ); + // FENCE LOST: somebody else owns this row now. Touch NOTHING — + // above all not the object, which the winner may be using. + if (!dropped.ok) continue; + await settleQueuedCleanup(dropped.eventId, row.storagePath, cleanupNotBefore(row)); + rejected++; + } + if (published || parked || rejected || leaseActive) { + console.log("[cron/receipt-intake-worker] STAGING sweep", JSON.stringify({ + published, parked, rejected, "upload-lease-active": leaseActive, + })); + } + return published + parked + rejected; + }, + + // ONLY this project's phases — no company-wide fallback, and an empty + // list is a real answer. + // + // Returning every active cost code meant the model was offered phases + // the job does not have, so it confidently suggested one, and booking + // then had to throw that suggestion away (isCostCodeAllowedForProject). + // The visible symptom was receipts arriving uncoded for no stated + // reason; the real cost is that a plausible-but-wrong phase is exactly + // the kind of thing a reviewer accepts without checking. + // + // A row with no project has no phases at all. Suggesting one from the + // whole company would be a guess with nothing behind it. + loadPhases: async projectId => { + if (!projectId) return []; + const phases = await resolveProjectPhaseCodes(prismaPhaseDataSource, projectId); + return phases.map(phase => ({ id: phase.id, code: phase.code, name: phase.name })); + }, + + downloadBytes: (storagePath, expectedSha256) => + // THE INVOCATION deadline, threaded into storage exactly as it is + // into QuickBooks: a hung download must return control in time for + // the pass to release the rows it claimed. + downloadVerified(storagePath, expectedSha256, invocationDeadline), + + // The invocation's ONE deadline, not a fresh 25s per row (see + // readBudgetFor). A row reached late in the batch gets whatever + // runway is actually left instead of a full budget stacked on top + // of what the run has already spent — the same deadline that + // already governs every QuickBooks call below. + read: (bytes, mime, phases) => { + const budgetMs = readBudgetFor(remainingBudgetMs(invocationDeadline)); + if (budgetMs <= 0) { + // Same answer readReceipt gives for an exhausted budget: the + // document was never read, so this costs no `attempts` — the + // row comes back next pass with a full budget again. + return Promise.resolve({ ok: false, decisive: false }); + } + return readReceipt(bytes, mime, phases, { budgetMs }); + }, + + applyRead: async (rowId, patch: ReadPatch, ownership) => { + try { + // CAS on {id, state, claimToken}. nextRetryAt is deliberately + // UNTOUCHED: the claim lease must survive until routing + // finishes. Clearing it here let an overlapping invocation + // reclaim a half-routed row and book it while this one was + // still deciding — and then this one would regress it. + // finishRouting()/applyState() release the lease. + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: ownership.state, claimToken: ownership.claimToken }, + data: { ...patch, lastError: null }, + }); + return { strongOwner: null, owned: count > 0 }; + } catch (error) { + // The partial unique index refused the claim — the DATABASE is + // the lock the Apps Script did with Script Properties. Load the + // owner so the caller can compare totals. + // Which constraint fired is resolved by looking the owner up + // BY dedupStrongKey — a fact about the data — rather than by + // string-matching Prisma's `meta`, whose shape is version + // dependent and is empty for a partial index on some engine + // builds (i.e. exactly this index). + if (!isUniqueViolation(error) || !patch.dedupStrongKey) throw error; + const owner = await prisma.receiptIntake.findFirst({ + where: { + dedupStrongKey: patch.dedupStrongKey, + state: { notIn: ["DUPLICATE", "VOID"] }, + id: { not: rowId }, + }, + select: { id: true, totalCents: true, vendor: true }, + }); + // No owner means some OTHER unique constraint rejected the + // write; re-throw rather than reporting a dedup hit that isn't. + if (!owner) throw error; + return { + owned: true, + strongOwner: { + id: owner.id, + totalCents: owner.totalCents, + canonicalVendor: owner.vendor ? canonicalVendor(owner.vendor) : null, + }, + }; + } + }, + + findWeakHit: async (rowId, weakKey) => prisma.receiptIntake.findFirst({ + where: { + dedupWeakKey: weakKey, + id: { not: rowId }, + state: { notIn: ["DUPLICATE", "VOID", "NON_RECEIPT"] }, + }, + select: { id: true }, + orderBy: { createdAt: "asc" }, + }), + + applyState: async (rowId, state, stateReason, patch, ownership) => { + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: ownership.state, claimToken: ownership.claimToken }, + data: { ...(patch ?? {}), state, stateReason, nextRetryAt: null, ...RELEASE_CLAIM }, + }); + return count > 0; + }, + + // RECEIVED -> READ, and the ONLY place the routing lease is released. + finishRouting: async (rowId, claimToken, stateReason, taxWarning) => { + // FENCED on state AND token, and it clears both claim fields. + // + // The state alone is not enough: a worker whose invocation was + // killed mid-routing can resume after its row has been re-claimed + // and re-read, find it back in RECEIVED, and publish READ over the + // successor's work — including over a NEEDS_REVIEW the successor + // had every reason to set. Matching the token makes that write + // affect zero rows instead. + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: "RECEIVED", claimToken }, + data: { + state: "READ", + stateReason, + // The DURABLE copy. Nothing downstream writes this column, + // so it is still there when the row reaches BOOKED. + taxWarning, + nextRetryAt: null, + claimToken: null, + claimedAt: null, + }, + }); + if (count === 0) { + console.warn("[cron/receipt-intake-worker] finishRouting fenced out", rowId); + } + }, + + companyTimeZone: resolveCompanyTimeZone, + + // WITH THE INVOCATION'S DEADLINE. The default dependency passes none, + // so every delete took a fresh fifteen seconds -- long enough to + // outlive the claim it was running under, whatever the pass had left. + retryStorageCleanups: shouldStop => retryPendingCleanups( + STAGING_SWEEP_BATCH, + shouldStop, + liveSweepDepsFor(invocationDeadline), + ), + + promoteToBooking: async (rowId, weakKey, claimToken) => prisma.$transaction(async tx => { + // LAST weak-dedup check, taken INSIDE the transition. The check at + // read time can miss a pair that arrived in the same batch window, + // and READ -> BOOKING is the last instant before money moves. + // + // WHY A SECOND LOCK, keyed on the weak key rather than just relying + // on the global claim lock: that lock is transaction-scoped and is + // released the moment the CLAIM transaction commits, which is + // before any row is processed. Holding it across the whole pass + // instead would mean one long-lived transaction wrapping every + // Gemini and QuickBooks call — minutes of open transaction on a + // pgbouncer pool, which is exactly what the pooler cannot afford. + // + // So the serialization is narrowed to what actually needs it. Two + // rows sharing a weak key take the SAME lock here and go one at a + // time; the loser's SELECT then sees the winner already in BOOKING. + // Without it both SELECTs can run before either UPDATE commits + // (classic write skew, and READ COMMITTED will not catch it because + // neither row writes what the other read) and both documents book. + // Rows with different weak keys take different locks and never + // block each other. + if (weakKey) { + // $executeRaw, NOT $queryRaw. pg_advisory_xact_lock returns + // VOID: `SELECT` of it produces a row whose single column has no + // readable type, and Prisma's query path can reject that outright + // — which would throw INSIDE the promotion transaction and, on + // the retry path, look like a transient DB fault forever while + // the lock was never actually taken. $executeRaw runs the + // statement for its effect and asks nothing of the result. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${weakKey}, 0))`; + // EVERY LIVE STATE, not just the post-booking ones. + // + // Limiting this to BOOKING/BOOKED/ARCHIVED meant a twin sitting + // in NEEDS_REVIEW — which is exactly where the weak net puts + // the FIRST of a suspected pair — was invisible here, so the + // second copy sailed past the human decision that was still + // pending on the first and booked itself. A twin awaiting + // review is the strongest possible signal to stop, not the + // weakest. + // + // DUPLICATE / VOID / NON_RECEIPT are excluded because those are + // settled: somebody already decided they are not a purchase. + const conflict = await tx.receiptIntake.findFirst({ + where: { + dedupWeakKey: weakKey, + id: { not: rowId }, + state: { notIn: ["DUPLICATE", "VOID", "NON_RECEIPT"] }, + }, + select: { id: true }, + orderBy: { createdAt: "asc" }, + }); + if (conflict) { + await tx.receiptIntake.updateMany({ + where: { id: rowId, state: "READ", claimToken }, + data: { + state: "NEEDS_REVIEW", + stateReason: `weak-dup:${conflict.id}`, + // Parked without ever reaching QuickBooks, so the + // strong key goes back (same rule as book.ts). + dedupStrongKey: null, + nextRetryAt: null, + ...RELEASE_CLAIM, + }, + }); + return { promoted: false, conflictId: conflict.id }; + } + } + // CAS: only the current claim holder promotes. A superseded worker + // must not move a row into BOOKING that its successor is handling. + // + // THE ONE TRANSITION THAT KEEPS THE CLAIM, deliberately: promotion + // hands the row straight to bookReceipt in this same pass, and both + // its send mark and its BOOKED commit CAS on this token. Releasing + // here would admit a second worker to the same booking. + // + // stateReason is left UNTOUCHED, not cleared: finishRouting is the + // ONLY path to READ (see worker.ts), and it never writes anything + // to this column besides null or "tax-implausible" — so whatever a + // READ row is carrying here is exactly that warning, and it must + // survive into BOOKING/BOOKED or an automatically booked receipt + // with a bad tax read becomes indistinguishable from one with no + // tax read at all. + const { count } = await tx.receiptIntake.updateMany({ + where: { id: rowId, state: "READ", claimToken }, + data: { state: "BOOKING" }, + }); + if (count === 0) return { promoted: false, stale: true }; + return { promoted: true }; + }), + + book: row => bookReceipt(row, { + db: prisma as unknown as BookPrismaClient, + companyTimeZone: resolveCompanyTimeZone, + markSendAttempted: async (rowId, claimToken) => { + // CAS: only the CURRENT claim holder may mark a send. A zero + // count means this worker was superseded, and bookReceipt + // aborts on it before touching QuickBooks. + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: "BOOKING", claimToken }, + data: { sendAttempted: true }, + }); + return count > 0; + }, + isCostCodeAllowed: (projectId, costCodeId) => + isCostCodeAllowedForProject(prismaPhaseDataSource, projectId, costCodeId), + // The invocation's ONE absolute deadline, created at entry and + // shared by every check and every QuickBooks call. Never a + // remaining-milliseconds snapshot: that is measured once and then + // decays silently while the work runs. + deadline: invocationDeadline, + isPushEnabled: () => process.env.QBO_RECEIPT_PUSH_ENABLED === "true", + isPushPaused: () => isPaused(PAUSE_KEYS.receiptPush), + // Same env read as the worker's own isDryRunEnabled — read fresh + // here too, since book() is the last stop before a real QBO write. + isDryRunEnabled: () => process.env.RECEIPT_INTAKE_DRYRUN !== "false", + getTokens: deadline => getFreshQBTokens(deadline), + createPurchase: (tokens, input, deadline, onBeforeCreate, onExistingPurchase) => + createQBReceiptPurchase(tokens, input, { onBeforeCreate, onExistingPurchase }, deadline), + downloadBytes: (storagePath, expectedSha256) => + // THE INVOCATION deadline, threaded into storage exactly as it is + // into QuickBooks: a hung download must return control in time for + // the pass to release the rows it claimed. + downloadVerified(storagePath, expectedSha256, invocationDeadline), + logEvent: logAutomationEvent, + now: () => new Date(), + }), + + applyBookResult: async (rowId, result, claimToken) => { + const now = new Date(); + if (result.outcome === "booked") return; // bookReceipt already committed it + // A superseded worker writes NOTHING: the row belongs to whoever + // holds the current token, and its state is theirs to set. + if (result.outcome === "stale") return; + // Every write below is a CAS on the claim AND on the state. + // + // The token alone is not enough here: bookReceipt own commit may + // already have moved the row to BOOKED under this same token, and a + // late deferred/retry result would then overwrite a booked row with + // "come back in an hour". Pinning BOOKING means only a row still + // waiting to book can be written by a booking result. + const owns = { id: rowId, state: "BOOKING", claimToken } as const; + if (result.outcome === "needs-review") { + await prisma.receiptIntake.updateMany({ + where: owns, + data: { + state: "NEEDS_REVIEW", + stateReason: result.reason, + nextRetryAt: null, + ...RELEASE_CLAIM, + // Parked before any QBO send: hand the strong key back, + // or a corrected re-send of the same receipt would be + // quarantined against a row that never became a purchase. + ...(result.releaseStrongKey ? { dedupStrongKey: null } : {}), + }, + }); + return; + } + if (result.outcome === "deferred") { + // A switch is off: hold in BOOKING, look again in an hour, and + // do NOT spend an attempt — this document did nothing wrong. + await prisma.receiptIntake.updateMany({ + where: owns, + data: { + state: "BOOKING", + stateReason: result.reason, + nextRetryAt: new Date(now.getTime() + 60 * 60_000), + ...RELEASE_CLAIM, + }, + }); + return; + } + await prisma.receiptIntake.updateMany({ + where: owns, + data: { + state: "BOOKING", + attempts: result.attempts, + lastError: result.reason.slice(0, 400), + nextRetryAt: result.nextRetryAt, + ...RELEASE_CLAIM, + }, + }); + }, + + deferRead: async (rowId, busyPasses, reason, ownership) => { + // The service was unavailable; the document was never read, so this + // costs no `attempts` — only a delay and one busy pass. Reuses the + // booking backoff table so one outage does not hammer Gemini from + // every row at once. + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: ownership.state, claimToken: ownership.claimToken }, + data: { + busyPasses, + lastError: reason, + nextRetryAt: new Date(Date.now() + backoffMs(1)), + ...RELEASE_CLAIM, + }, + }); + return count > 0; + }, + + // Hand the row back untouched except for when to look at it again. No + // state change, no attempt spent, no lastError: the dry-run switch is + // not a verdict on the document. Fenced like every other write, so a + // superseded pass releases nothing. + releaseClaim: async (rowId, nextRetryAt, ownership) => { + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: ownership.state, claimToken: ownership.claimToken }, + data: { nextRetryAt, ...RELEASE_CLAIM }, + }); + return count > 0; + }, + + // Every row the soft deadline cut off, in one token-fenced write per + // claim token. `nextRetryAt: null` puts them back at the front of the + // queue: they were never worked on, so there is nothing to back off + // from, and the ten-minute claim lease they are carrying was written + // for a pass that has ended. + releaseUnprocessed: async rows => { + const byToken = new Map(); + for (const row of rows) { + // A row with no token was never really claimed; there is + // nothing to fence a release on and nothing to release. + if (!row.claimToken) continue; + const ids = byToken.get(row.claimToken); + if (ids) ids.push(row.id); + else byToken.set(row.claimToken, [row.id]); + } + let released = 0; + for (const [claimToken, ids] of byToken) { + const { count } = await prisma.receiptIntake.updateMany({ + // FENCED ON THE TOKEN THIS PASS CLAIMED WITH. A row whose + // token changed belongs to a successor, and clearing its + // claim here would hand a live pass's row to a third one. + where: { id: { in: ids }, claimToken }, + data: { nextRetryAt: null, ...RELEASE_CLAIM }, + }); + released += count; + } + return released; + }, + + retryRow: async (rowId, attempts, nextRetryAt, reason, ownership) => { + const { count } = await prisma.receiptIntake.updateMany({ + where: { id: rowId, state: ownership.state, claimToken: ownership.claimToken }, + data: { attempts, lastError: reason, nextRetryAt, ...RELEASE_CLAIM }, + }); + return count > 0; + }, + + // Re-read taken RIGHT BEFORE routing, after the download and the model + // call. A late job assignment landing in that window must not be routed + // over: NEEDS_JOB for a receipt that HAS a job sends a human looking for + // a problem that no longer exists. + sendAttemptedNow: async rowId => { + const row = await prisma.receiptIntake.findUnique({ + where: { id: rowId }, + select: { sendAttempted: true }, + }); + // A row that vanished, or a read that returned nothing, is answered + // "a send may have happened": retaining the key costs a review, and + // releasing it wrongly costs a second Purchase. + return row?.sendAttempted ?? true; + }, + + refreshProjectId: async rowId => { + const row = await prisma.receiptIntake.findUnique({ + where: { id: rowId }, + select: { projectId: true }, + }); + return row?.projectId ?? null; + }, + + now: () => new Date(), + monotonicMs: () => Date.now(), + }; +} + +export async function GET(request: Request) { + // isCronAuthorized: constant-time compare, and it fails CLOSED. + // + // The hand-rolled version this replaces had both problems the shared helper + // exists to fix. `authHeader === \`Bearer ${secret}\`` is a byte-at-a-time + // string compare that leaks the secret to anyone who can time the response. + // Worse, the `isLocalDev` escape hatch — no VERCEL, NODE_ENV not + // "production", and no CRON_SECRET — is satisfied by an unset environment, + // so any container, self-hosted build, or preview whose env drifted served + // this endpoint to anyone who asked. On a route that books real money into + // QuickBooks. + if (!isCronAuthorized(request)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const summary = await runIntakeWorker(buildDeps(createRouteDeadline(RUN_HARD_BUDGET_MS))); + if (summary.processed > 0 || summary.skipped) { + console.log("[cron/receipt-intake-worker]", JSON.stringify(summary)); + } + return NextResponse.json(summary); +} diff --git a/src/app/api/receipts/intake/[id]/archived/route.ts b/src/app/api/receipts/intake/[id]/archived/route.ts new file mode 100644 index 000000000..21acdf6ff --- /dev/null +++ b/src/app/api/receipts/intake/[id]/archived/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { authenticateIntake } from "@/lib/receipt-intake/intake-auth"; +import { SOURCE_REF_PATTERNS } from "@/lib/receipt-intake/intake-core"; + +export const dynamic = "force-dynamic"; + +/** + * Archive callback for the nightly Apps Script mirror + * (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §6): the script copies each BOOKED + * receipt into `Processed Receipts/YYYY/MM/` with the v1 filename convention + * and then reports the Drive file id back here. + * + * SECRET-AUTH ONLY. There is no session path: this transition means "a file + * exists in Drive", which only the mirror can know, and a staff user clicking + * it would be asserting something they cannot verify. + * + * NOTE: this path is a DESCENDANT of /api/receipts/intake, and the proxy + * bypass there is exact-match on purpose — so this route DOES go through the + * proxy. It carries no NextAuth session, so the proxy answers it with a 307 to + * /login unless it is on the bypass too; both paths are listed in + * PUBLIC_PROXY_BYPASS_PATTERN for that reason, each one exact. + */ +export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { + // ARCHIVE capability only. This transition means "a file exists in Drive", + // which only the mirror can know — and the ingest forwarders must not be + // able to mark rows archived just because they hold a receipt-intake key. + const auth = await authenticateIntake(req, "archive"); + if (!auth.ok) return auth.response; + if (auth.via !== "secret") { + // No session path: a staff user clicking this would be asserting + // something they cannot verify. + return NextResponse.json({ ok: false, reason: "unauthorized" }, { status: 401 }); + } + + const { id } = await context.params; + + let body: { driveFileId?: unknown }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 }); + } + const driveFileId = typeof body.driveFileId === "string" ? body.driveFileId.trim() : ""; + if (!driveFileId) { + return NextResponse.json({ ok: false, reason: "missing-driveFileId" }, { status: 400 }); + } + // SAME shape rule a `drive` sourceRef's tail is held to (intake-core.ts): + // a real Drive file id, not an arbitrary string. This value is written to + // `archiveDriveFileId`, echoed in logs, and compared for equality on every + // replay — "any non-empty string" let a single stray character ("x") or an + // oversized payload become the permanent archive identity for a row. + if (!SOURCE_REF_PATTERNS.drive.test(driveFileId)) { + return NextResponse.json({ ok: false, reason: "invalid-driveFileId" }, { status: 400 }); + } + + const row = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { id: true, state: true, archiveDriveFileId: true }, + }); + if (!row) return NextResponse.json({ ok: false, reason: "not-found" }, { status: 404 }); + + // IDEMPOTENT REPLAY. The mirror POSTs after writing the Drive file, so a + // lost response leaves it holding a file it cannot confirm. Re-sending the + // SAME driveFileId is the correct retry and must succeed — answering 409 + // would make the script treat its own successful archive as a failure and + // either re-copy the file or alert a human about nothing. + // A DIFFERENT driveFileId on an archived row is not a replay: two Drive + // copies exist and somebody has to say which one counts. + if (row.state === "ARCHIVED") { + if (row.archiveDriveFileId === driveFileId) { + return NextResponse.json({ ok: true, id, state: "ARCHIVED", archiveDriveFileId: driveFileId, alreadyArchived: true }); + } + return NextResponse.json( + { ok: false, reason: "already-archived", archiveDriveFileId: row.archiveDriveFileId }, + { status: 409 }, + ); + } + + if (row.state !== "BOOKED") { + return NextResponse.json({ ok: false, reason: "not-booked", state: row.state }, { status: 409 }); + } + + // Conditional on state so two mirror runs racing the same row cannot both + // claim the transition; the loser sees 0 rows and reports 409. + const updated = await prisma.receiptIntake.updateMany({ + where: { id, state: "BOOKED" }, + data: { state: "ARCHIVED", archiveDriveFileId: driveFileId }, + }); + if (updated.count === 0) { + // We lost a race. Two identical callbacks (the mirror retrying a lost + // response) can both read BOOKED; the winner archives and the loser's + // conditional update matches nothing. Returning 409 on that made the + // mirror treat its OWN successful archive as a failure. Re-read: if the + // row is now ARCHIVED with the same Drive id, the outcome the caller + // asked for is exactly what happened. + const now = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { state: true, archiveDriveFileId: true }, + }); + if (now?.state === "ARCHIVED" && now.archiveDriveFileId === driveFileId) { + return NextResponse.json({ ok: true, id, state: "ARCHIVED", archiveDriveFileId: driveFileId, alreadyArchived: true }); + } + return NextResponse.json( + { ok: false, reason: "not-booked", state: now?.state ?? "gone" }, + { status: 409 }, + ); + } + return NextResponse.json({ ok: true, id, state: "ARCHIVED", archiveDriveFileId: driveFileId }); +} diff --git a/src/app/api/receipts/intake/[id]/finalize/route.ts b/src/app/api/receipts/intake/[id]/finalize/route.ts new file mode 100644 index 000000000..d2e5ffd75 --- /dev/null +++ b/src/app/api/receipts/intake/[id]/finalize/route.ts @@ -0,0 +1,695 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { authenticateIntake, STAFF_READ_ROLES, type IntakeAuth } from "@/lib/receipt-intake/intake-auth"; +import { userCanAccessProject } from "@/lib/mobile-auth"; +import { isCostCodeAllowedForProject } from "@/lib/project-phases"; +import { prismaPhaseDataSource } from "@/lib/project-phases-db"; +import { MAX_STORED_BYTES } from "@/lib/receipt-intake/intake-core"; +import { + declaredShaConflict, + finalizeDisposition, + inspectStoredObject, + leaseFence, + sealAndPublish, + verifyStoredCopy, +} from "@/lib/receipt-intake/stored-object"; +import { + authorizeEffectiveProject, + authorizePhase, + mergeCapturedFields, + reconcileLateFields, + type Denial, + type LateFields, +} from "@/lib/receipt-intake/late-fields"; +import { + deleteObjectOrRecord, + queueObjectCleanup, + claimObjectPath, + resolveCanonicalIntent, + rejectRowAndQueueCleanup, + sealObject, + settleQueuedCleanup, + inShortTx, +} from "@/lib/receipt-intake/storage-cleanup"; +import { cleanupNotBefore } from "@/lib/receipt-intake/worker"; +import { createRouteDeadline, type RouteDeadline } from "@/lib/quickbooks"; + +export const dynamic = "force-dynamic"; + +/** + * A "we already have it" answer has to be TRUE — and "it" means THIS DOCUMENT. + * + * Both replay paths used to return success from the row alone. The forwarders + * treat that as permission to delete their only copy — so a row whose object had + * gone missing (a bad publish, a cleanup that ran on the wrong path, a bucket + * incident) got a cheerful 200 and the receipt ceased to exist anywhere. + * + * PRESENCE WAS NOT ENOUGH EITHER. Confirming that SOMETHING sits at the path + * authorised the sender to delete its source copy on the strength of bytes + * nobody had looked at since they were sealed — so an object replaced or + * corrupted after publication (an upsert URL reused, a restore that put back a + * different version, a storage-side fault) was laundered into "we have your + * receipt" and the only good copy was then deleted by the sender. The row's + * `fileSha256` is the one hash this system ever verified; the stored bytes must + * still hash to it. + * + * Cheap probe first — one small `list` regardless of the object's size — so the + * common orphan case never pays for a download at all. The answers are + * deliberately different: an absence is a 409 the sender can act on by + * re-uploading, a storage fault is a 503 it should simply retry, a CONTENT + * mismatch is a 409 that must never look like success (the row is left exactly + * as it is, for the worker and the sweeper to act on), and only verified bytes + * are success. + */ +async function confirmStoredCopy( + storagePath: string, + fileSha256: string, + /** The REQUEST's deadline, not a fresh allowance for this probe. */ + deadline: RouteDeadline | undefined, +): Promise { + const held = await verifyStoredCopy(storagePath, fileSha256, deadline); + if (held.ok) return null; + if (held.kind === "transient") { + return NextResponse.json({ ok: false, reason: "storage-unavailable", retryable: true }, { status: 503 }); + } + if (held.kind === "content-mismatch") { + return NextResponse.json( + { + ok: false, + error: "content-mismatch", + reason: "the stored document is not the one this row was published with; keep your copy and escalate", + retryable: false, + }, + { status: 409 }, + ); + } + return NextResponse.json( + { + ok: false, + error: "file-missing", + reason: "this row exists but its stored document is gone; send the bytes again", + retryable: true, + }, + { status: 409 }, + ); +} + +/** + * Route adapter over the late-field rules (src/lib/receipt-intake/late-fields.ts). + * + * The rules live in a lib because their interesting behaviour is entirely about + * races — a worker claiming the row, a state transition, a second caller + * writing a different project — and none of that is reachable from a test that + * has to stand up a route handler. + */ +async function applyLateFields( + id: string, + lateFields: LateFields, + auth: Extract, +): Promise { + const denial = await reconcileLateFields(id, lateFields, { + read: rowId => prisma.receiptIntake.findUnique({ + where: { id: rowId }, + select: { costCodeId: true, projectId: true, state: true, claimToken: true }, + }), + applyIfNull: async (rowId, state, toApply) => { + const { count } = await prisma.receiptIntake.updateMany({ + where: { + id: rowId, + state, + claimToken: null, + ...Object.fromEntries(Object.keys(toApply).map(key => [key, null])), + }, + data: toApply, + }); + return count; + }, + authorize: projectId => authorizeFinalization(auth, projectId, lateFields), + }); + return denial ? NextResponse.json(denial.body, { status: denial.status }) : null; +} + +/** + * A caller may only finalize against a job it can actually reach, and may only + * attach a phase that belongs to that job. + * + * Without the first check any authenticated user could file a receipt against + * any project by id — and, before this authorized the EFFECTIVE project rather + * than only a supplied one, a user whose access had been revoked could still + * publish and phase their existing row on that job simply by not mentioning it. + * Without the second, a cost code from another job rides into the Expense and + * every variance report reads it as overspend on a line nobody budgeted. + */ +async function authorizeFinalization( + auth: Extract, + rowProjectId: string | null, + lateFields: LateFields, +): Promise { + const projectId = lateFields.projectId ?? rowProjectId; + + // EVERY session call, not just the ones carrying a project. The shared + // secret is a trusted forwarder with no user to scope by. + if (auth.via === "session") { + const forbidden = await authorizeEffectiveProject( + rowProjectId, + lateFields.projectId ?? null, + candidate => userCanAccessProject(auth.user, candidate), + ); + if (forbidden) return forbidden; + } + + // Same rule, same implementation, as /start applies to a phase supplied + // there — the two must never be able to disagree. + return await authorizePhase(projectId, lateFields.costCodeId ?? null, (project, code) => + isCostCodeAllowedForProject(prismaPhaseDataSource, project, code)); +} +export const maxDuration = 30; + +/** + * THE ROUTE'S ONE ABSOLUTE DEADLINE, created at entry. + * + * This handler makes up to four storage calls -- the size probe, the + * download, the seal upload and, on a reject, the delete -- and it made + * every one of them with no deadline at all, so each took a fresh fifteen + * seconds inside a request the platform kills at thirty. + */ +const ROUTE_BUDGET_MS = 27_000; + +/** + * Step 2 of the two-step upload: verify what actually landed, then publish. + * + * Everything here is checked against the STORED OBJECT, never against what the + * client says about it. The client uploaded directly to Supabase, so this is the + * only point at which the server sees the bytes at all — trusting a declared + * hash, size or type would mean the row's `fileSha256` (which decides whether a + * replay is a duplicate or a conflict) was attacker-supplied. + * + * STAGING -> RECEIVED is the publish, and it is the only thing that makes the + * row visible to the worker. + */ +export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { + // ONE deadline for the whole request -- see ROUTE_BUDGET_MS. + const deadline = createRouteDeadline(ROUTE_BUDGET_MS); + const auth = await authenticateIntake(req, "ingest"); + if (!auth.ok) return auth.response; + + const { id } = await context.params; + + // A GENUINELY empty body means "no fields" — the declared hash and the + // late fields are all optional. But `req.json()` throws on malformed JSON + // too, and a bare try/catch could not tell the two apart: a truncated or + // corrupted body was silently treated as an empty one, so a request-level + // bug never surfaced as an error the caller could see or retry against. + // Reading the raw text first is what makes the difference legible: only a + // body that is empty (or whitespace) after trimming may mean "no fields". + let body: { + sha256?: unknown; + uploadLease?: unknown; + costCodeId?: unknown; + projectId?: unknown; + } = {}; + const rawBody = await req.text(); + if (rawBody.trim()) { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 }); + } + // Parsing can SUCCEED on a value that is not "no fields" either: a bare + // string, number, boolean, or array is valid JSON, but every field read + // off it (body.sha256, body.costCodeId, ...) comes back undefined — + // indistinguishable from a genuinely empty body, so it fell through to + // the same 200 with nothing applied. Only a plain object can carry late + // fields; anything else is refused the same as malformed JSON. + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 }); + } + body = parsed as typeof body; + } + const declaredSha = typeof body.sha256 === "string" ? body.sha256.trim().toLowerCase() : null; + // THE LEASE THIS CALLER'S URL WAS ISSUED UNDER. Required — see the gate below. + const declaredLease = typeof body.uploadLease === "string" && body.uploadLease.trim() + ? body.uploadLease.trim() + : null; + + // LATE FIELDS. A client that learned the job only after starting the upload + // sends them here. They are applied WHERE NULL and refused where they + // disagree — silently overwriting a value a human already set is the one + // outcome that loses information nobody can recover. + // + // NOTE: Phase 3's `installedAtCustomer` does not exist on this model; the + // same rule will apply to it when it lands. + const lateInput = { + costCodeId: typeof body.costCodeId === "string" && body.costCodeId.trim() ? body.costCodeId.trim() : null, + projectId: typeof body.projectId === "string" && body.projectId.trim() ? body.projectId.trim() : null, + }; + const lateFields = Object.fromEntries( + Object.entries(lateInput).filter(([, v]) => v !== null), + ) as Partial<{ costCodeId: string; projectId: string }>; + + const row = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { + id: true, source: true, state: true, stateReason: true, sourceRef: true, storagePath: true, + mimeType: true, projectId: true, costCodeId: true, dryRun: true, createdById: true, + fileSha256: true, expectedSha256: true, uploadLeaseVersion: true, + // Read for ONE reason: the signed upload URL this row was given is + // a write capability that outlives every decision below, so every + // object deletion on this path has to be scheduled for after it + // dies rather than taken now. See cleanupNotBefore. + uploadUrlExpiresAt: true, createdAt: true, + // The lease GENERATION. Read so leaseFence can pin it: a /start + // refresh moves nothing else on this row, so without it an + // in-flight finalizer publishes (or rejects) over a lease that has + // already been reissued to a client still holding a working URL. + uploadLeaseNonce: true, + }, + }); + if (!row) return NextResponse.json({ ok: false, reason: "not-found" }, { status: 404 }); + + + + // WHEN THIS ROW'S OBJECT MAY BE DELETED, computed ONCE from the row as + // read, so the reject path and the seal path cannot disagree about it. + // Null means the upload lease is already dead and an immediate delete is + // correct — which is the case the stale-STAGING sweep is always in, since + // it refuses to do anything destructive while a lease is live. + const cleanupAfter = cleanupNotBefore(row); + + // A SECRET OWNS SOURCES, NOT ROWS. + // + // decideSource already refuses to let a forwarder CREATE a row outside its + // own namespace, but 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 a web upload that belongs to a person. The + // same list that scopes creation scopes this: an ingest key owns + // drive/email/chat, and nothing else. + // + // 403, not 404: the caller is authenticated and the row is real; what it + // lacks is authority. Checked BEFORE any detail is returned or written. + if (auth.via === "secret" && !auth.allowedSources.has(row.source)) { + return NextResponse.json( + { + ok: false, + error: "source-not-owned", + reason: `this key does not own ${row.source} rows`, + }, + { status: 403 }, + ); + } + + // Same rule as the conflict path: a session caller may only finalize its + // OWN row (or hold a bookkeeping role). Otherwise a guessed id would let one + // user publish another's upload. + const maySee = + auth.via === "secret" || + row.createdById === auth.user.id || + STAFF_READ_ROLES.includes(auth.user.role); + if (!maySee) return NextResponse.json({ ok: false, reason: "not-found" }, { status: 404 }); + + // ── BOUND TO THE LEASE THAT ISSUED THIS CALLER'S URL ─────────────────── + // + // /start rotates `uploadLeaseNonce` on every issue and every adoption, and + // it now RETURNS that value. This gate is what makes returning it mean + // something: a finalizer must present the generation its own signed URL was + // issued under, and one that does not match the row's current lease is + // refused before it reads a single byte. + // + // Reading the row's CURRENT nonce (which is all this used to do, implicitly) + // meant a delayed finalizer silently ADOPTED whichever lease had been + // issued since. Both /start calls hand out URLs for the SAME path, so: + // client A starts an upload, client B's retry refreshes the lease and gets + // a working URL, A's finalize finally arrives, inspects B's half-written + // object, finds it unacceptable and DELETES the row — while B is still + // uploading to a URL that works. The row, and the only record of an inbound + // receipt, are gone. + // + // BEFORE the storage inspection and before every mutation, deliberately: + // this must cost nothing and touch nothing. 409, and `retryable` is false — + // the caller cannot fix a stale lease by retrying the same request; it has + // to call /start again and use the URL that comes back. + // + // A row with a NULL nonce was never issued a signed URL at all (the + // single-shot inline path writes its bytes through the server), so no + // caller can hold a valid generation for it and the comparison correctly + // refuses every two-step finalize against one. + if (!declaredLease || declaredLease !== row.uploadLeaseNonce) { + return NextResponse.json( + { + ok: false, + error: "lease-stale", + reason: declaredLease + ? "this upload lease has been superseded; call /start again for a fresh URL" + : "uploadLease is required — send the value /start returned with your URL", + retryable: false, + }, + { status: 409 }, + ); + } + + // THE ROW, PINNED TO THE LEASE THE CALLER PROVED IT HOLDS. + // + // Equal to `row` by the gate immediately above — and written this way on + // purpose. Every fence below is built from the value the CLIENT echoed + // rather than from whatever the freshly-read row happened to say, so a + // future edit that moved or weakened the gate would leave these CASes + // pinning a generation nobody proved, which is the whole bug. + const leased = { ...row, uploadLeaseNonce: declaredLease }; + + // THE DECLARED HASH IS ENFORCED HERE, ONCE, AHEAD OF EVERY OUTCOME. + // + // It used to be checked on the publish path alone — against the STORED + // BYTES, further down — which meant a finalize against an already-settled + // row (RECEIVED, READ, BOOKED) verified storage against the ROW's hash, + // never looked at the hash the REQUEST carried, and answered 200 + // alreadyFinalized. A forwarder holding a stale or wrong row id was + // therefore told we had ITS receipt while we had a different one, and it + // deletes its only copy on that answer. + // + // Placed above the disposition split rather than inside the settled branch + // so that no success response in this handler can be added later that + // bypasses it. The publish path still re-checks the declared hash against + // the bytes it just read, which is the stronger question and the only one + // a STAGING row (`fileSha256` is "") can be asked at all. + // + // Same rule /start already applies to its own settled replay: a recorded + // `fileSha256` that disagrees with the caller's hash is a 409, never a + // rebinding of this identity to different bytes. + if (declaredShaConflict(row.fileSha256, declaredSha)) { + return NextResponse.json( + { + ok: false, + error: "sha-mismatch", + reason: "this row holds a different document than the sha256 you declared", + recordedSha256: row.fileSha256, + state: row.state, + }, + { status: 409 }, + ); + } + + // Authorize the late fields BEFORE anything is written or published. + const denied = await authorizeFinalization(auth, row.projectId, lateFields); + if (denied) return NextResponse.json(denied.body, { status: denied.status }); + + // A LATE finalize on a row the sweeper already parked file-missing is a + // RECOVERY, not a duplicate: the upload landed after the sweep looked. It + // must re-validate and republish rather than report alreadyFinalized, which + // would leave a real receipt parked forever while telling the caller it was + // fine. Both sweeper parks are recoverable that way — bytes arriving after + // the sweep looked is the normal shape of a slow client, not an error state + // a human has to clear. Every OTHER park is a human's decision, and this + // path must not launder it into RECEIVED. + const disposition = finalizeDisposition(row); + if (disposition === "not-recoverable") { + return NextResponse.json( + { + ok: false, + error: "not-recoverable", + reason: "this row is parked for review; a re-upload does not clear it", + state: row.state, + stateReason: row.stateReason, + }, + { status: 409 }, + ); + } + const recoverable = disposition === "publish"; + + // Idempotent: finalizing an already-published row is a success, not an error + // — the client's retry after a lost response must not look like a failure. + // + // But the late fields are reconciled FIRST. A sequential retry arriving + // after the row already reached RECEIVED still carries them, and answering + // alreadyFinalized without applying them drops the job assignment on the + // floor while telling the caller it worked. Same behaviour as the + // two-publisher path below, because a caller cannot tell which one it hit. + if (!recoverable) { + // The object first: `alreadyFinalized` is what makes a forwarder drop + // its copy, so it must not be said about a row whose bytes are gone. + const unusable = await confirmStoredCopy(row.storagePath, row.fileSha256, deadline); + if (unusable) return unusable; + const conflict = await applyLateFields(id, lateFields, auth); + if (conflict) return conflict; + // PERSISTED values, re-read after the reconcile — the caller must be + // told what the row actually holds, not what it asked for. + const persisted = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { + state: true, sourceRef: true, projectId: true, costCodeId: true, + dryRun: true, fileSize: true, fileSha256: true, + }, + }); + return NextResponse.json({ ok: true, alreadyFinalized: true, id, ...(persisted ?? {}) }); + } + + // ONE validator, shared with the worker's stale-STAGING sweep — see + // stored-object.ts. If the two disagreed, whichever ran first would decide + // whether a 40 MB video became a receipt. + const check = await inspectStoredObject(row.storagePath, row.mimeType, deadline); + if (!check.ok) { + if (check.kind === "missing") { + // The upload never landed. Retryable, and NEVER a 2xx: the + // forwarders treat 2xx as "we have it" and would drop their copy. + return NextResponse.json( + { ok: false, error: "object-missing", reason: "upload the bytes to the signed URL first" }, + { status: 409 }, + ); + } + if (check.kind === "transient") { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + // REJECTED. The row goes, and so must the object — but the two writes + // are ONE transaction. A best-effort delete followed by a best-effort + // cleanup could drop the row and lose the object with nothing left + // referencing or remembering it. + // + // THE OBJECT DOES NOT GO YET, THOUGH. Unlike the sweeper, this path + // rejects a row whose signed upload URL is typically still live — + // /finalize is what a client calls minutes after /start — and deleting + // under a live write capability just invites the holder's delayed PUT + // to put the bytes back with the row already gone. The queue entry + // carries the schedule and IS the tombstone until then. + const rejected = await rejectRowAndQueueCleanup( + { + id, + state: row.state, + stateReason: row.stateReason, + storagePath: row.storagePath, + uploadLeaseVersion: row.uploadLeaseVersion, + // The lease GENERATION, so a /start that reissued this + // client's URL while we were inspecting the object makes this + // delete match zero rows instead of destroying a row whose + // upload link works again. + uploadLeaseNonce: leased.uploadLeaseNonce, + uploadUrlExpiresAt: row.uploadUrlExpiresAt, + cleanupNotBefore: cleanupAfter, + }, + check.reason, + ); + if (!rejected.ok) { + // The fence lost, so this row is not ours to reject: a publisher + // moved it (or claimed it) while we were inspecting the object. + // NOTHING is deleted — not the row, not the object, not even a + // cleanup record — because those bytes may now belong to a + // published receipt. + return NextResponse.json( + { + ok: false, + error: "publish-conflict", + reason: "this row changed while it was being rejected; retry", + retryable: true, + }, + { status: 409 }, + ); + } + // A no-op while the upload URL is still live; the sweep picks the + // event up once it is due. + await settleQueuedCleanup(rejected.eventId, row.storagePath, cleanupAfter); + const status = check.reason.startsWith("file-too-large") ? 413 : 400; + return NextResponse.json( + { ok: false, reason: check.reason, maxBytes: MAX_STORED_BYTES }, + { status }, + ); + } + + const { mimeType, fileSize, fileSha256 } = check; + + // THE HASH IS CHECKED AGAINST BOTH RECORDED EXPECTATIONS. + // + // `expectedSha256` was written by /start from what the client said it was + // about to upload; `declaredSha` is what it says now. Either disagreeing + // with the stored bytes means the object is not the document this row was + // created for — which is exactly the case a reused sourceRef produces, and + // the case that would otherwise attach one receipt's bytes to another + // receipt's identity. + for (const [label, expected] of [["declared", declaredSha], ["expected", row.expectedSha256]] as const) { + if (expected && expected.toLowerCase() !== fileSha256) { + return NextResponse.json( + { + ok: false, + error: "sha-mismatch", + reason: `stored bytes do not match the ${label} sha256`, + storedSha256: fileSha256, + }, + { status: 409 }, + ); + } + } + + // THE PUBLISH IS SUBJECT TO THE SAME NULL-OR-EQUAL RULE as every other + // late-field write. + // + // This used to spread `lateFields` straight into the publishing update, + // which made initial publication the one path that could silently REPLACE a + // job, a phase or a tax answer captured at /start. A client that captured + // the job at /start and sent a different one at finalize simply won, and + // nothing recorded that the first answer had ever existed. + const merged = mergeCapturedFields( + { projectId: row.projectId, costCodeId: row.costCodeId }, + lateFields, + ); + if ("status" in merged) return NextResponse.json(merged.body, { status: merged.status }); + + // AND THE RESULTING TUPLE IS VALIDATED, not the supplied half of it. + // + // authorizeFinalization above only saw what this REQUEST carried. A finalize + // that sends projectId=B against a phase captured for job A supplies a + // project that is fine on its own and a phase it never mentions — and the + // row ends up filed under B's job with A's phase. The check has to be on + // the pair the row will actually hold. + const mixed = merged.from.projectId !== merged.from.costCodeId; + const badTuple = await authorizePhase( + merged.resulting.projectId, + merged.resulting.costCodeId, + (project, code) => isCostCodeAllowedForProject(prismaPhaseDataSource, project, code), + ); + if (badTuple) { + // A caller's OWN bad pair is a 400 (fix the request). A pair only this + // MERGE created — half captured, half late — is a 409: the request is + // well-formed, it just disagrees with what the row already holds. + return mixed + ? NextResponse.json( + { + ...badTuple.body, + error: "captured-phase-conflict", + reason: "the phase already on this row is not a phase of the job you sent", + captured: { projectId: row.projectId, costCodeId: row.costCodeId }, + resulting: merged.resulting, + }, + { status: 409 }, + ) + : NextResponse.json(badTuple.body, { status: badTuple.status }); + } + + // ONE shared seal-and-publish, also used by the worker's stale-STAGING + // sweep, so the two publishers cannot diverge on ordering or fencing. + const outcome = await sealAndPublish(row.storagePath, id, row.uploadLeaseVersion, check, { + inShortTx, + seal: sealObject, + commit: async (tx, canonicalPath, values) => { + const { count } = await tx.receiptIntake.updateMany({ + // Fenced on the EXACT state and reason observed, on the row + // being unclaimed, and on every captured value this publish was + // validated against. Anything that moved between the read and + // this write — a re-park under a different reason, a worker + // claim, a job filled in — invalidates what was checked above, + // so the publish must lose rather than overwrite it. + where: { id, ...leaseFence(leased), ...merged.guard }, + data: { + state: "RECEIVED", + stateReason: null, + storagePath: canonicalPath, + mimeType: values.mimeType, + fileSize: values.fileSize, + fileSha256: values.fileSha256, + nextRetryAt: null, + // NULL-OR-EQUAL: only the fields the row does not already + // answer. Never a blind spread of what the caller sent. + ...merged.apply, + }, + }); + return count; + }, + // The UPLOAD path, whose signed URL is exactly the one the client just + // used — so this delete waits for that URL to die. Deleting the moment + // the pointer moved to the canonical path left the holder able to PUT + // the upload path back into existence, unreferenced and unremembered. + // + // ENQUEUED IN THE COMMIT TRANSACTION: the queue entry is the only thing + // that remembers this object once the row points elsewhere, so it must + // commit with the pointer or not at all. + queueUploadCleanup: (tx, uploadPath) => + queueObjectCleanup(tx, uploadPath, "sealed", cleanupAfter), + // The canonical copy is written before the pointer commit, so it is + // promised to the cleanup queue before it exists — same schedule, so a + // client still holding a live URL is never racing a sweep. + claimCanonicalPath: canonicalPath => claimObjectPath(canonicalPath, cleanupAfter), + resolveCanonicalIntent, + settleUploadCleanup: (eventId, uploadPath) => + settleQueuedCleanup(eventId, uploadPath, cleanupAfter).then(() => undefined), + }, deadline); + + if (!outcome) { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + if (!outcome.published) { + // The CAS lost — which is now TWO different things. Either another + // publisher moved the row (the caller's answer is "already finalized"), + // or a captured value changed underneath a row that is still waiting to + // publish, in which case nothing was published and saying otherwise + // would be a lie the client acts on. + // + // "Not STAGING" is NOT enough evidence of the first case. A concurrent + // /start rearm can move a recoverable NEEDS_REVIEW row onto a fresh + // upload lease without ever publishing it — the row is still + // NEEDS_REVIEW, but a re-armed upload can genuinely be present at the + // (new) storagePath, so `confirmStoredCopy` alone cannot tell "someone + // published" from "someone is mid re-upload of something else". The + // only positive proof that ANOTHER publisher published THIS content is + // that the row's canonical path now equals the one this call itself + // just verified (sealAndPublish names it after id+sha+mime) — that + // string can only be written by a successful sealAndPublish commit, and + // only with these exact bytes. + const current = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { state: true, sourceRef: true, projectId: true, dryRun: true, storagePath: true, fileSha256: true }, + }); + const positivelyPublished = !!current + && current.state !== "STAGING" + && current.storagePath === outcome.canonicalPath + && current.fileSha256 === fileSha256; + if (!positivelyPublished) { + return NextResponse.json( + { + ok: false, + error: "publish-conflict", + reason: "this row changed while it was being published; retry", + retryable: true, + }, + { status: 409 }, + ); + } + // Another publisher won, with the SAME content this call verified. + // Same outcome for the caller — but the late fields still have to be + // reconciled against what that publisher wrote, and the same "do we + // actually hold it" rule applies. + // Against `current.fileSha256`, not this call's `fileSha256`: the two + // were just proven equal by `positivelyPublished`, and the row's own + // value is what every later reader verifies against. + const unusable = await confirmStoredCopy(current.storagePath, current.fileSha256, deadline); + if (unusable) return unusable; + const reconciled = await applyLateFields(id, lateFields, auth); + if (reconciled) return reconciled; + return NextResponse.json({ ok: true, alreadyFinalized: true, id, state: current.state }); + } + + const persisted = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { + state: true, sourceRef: true, projectId: true, costCodeId: true, + dryRun: true, fileSize: true, fileSha256: true, + }, + }); + return NextResponse.json({ ok: true, id, ...(persisted ?? { state: "RECEIVED", fileSize }) }); +} diff --git a/src/app/api/receipts/intake/route.ts b/src/app/api/receipts/intake/route.ts new file mode 100644 index 000000000..b2bfcfa3f --- /dev/null +++ b/src/app/api/receipts/intake/route.ts @@ -0,0 +1,788 @@ +import { createHash, randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { userCanAccessProject } from "@/lib/mobile-auth"; +import { authorizePhase } from "@/lib/receipt-intake/late-fields"; +import { isCostCodeAllowedForProject } from "@/lib/project-phases"; +import { prismaPhaseDataSource } from "@/lib/project-phases-db"; +import { uploadReceiptObject } from "@/lib/receipt-intake/bucket"; +import { getSupabase } from "@/lib/supabase"; +import { authenticateIntake, STAFF_READ_ROLES, type IntakeAuth } from "@/lib/receipt-intake/intake-auth"; +import { + claimObjectPath, + deleteObjectOrRecord, + inShortTx, + recordPendingCleanup, + resolveCanonicalIntent, +} from "@/lib/receipt-intake/storage-cleanup"; +import { cleanupNotBefore } from "@/lib/receipt-intake/worker"; +import { ACCEPTED_MIME_TYPES, EXT_BY_MIME, sniffMime } from "@/lib/receipt-intake/file-type"; +import { + decideSource, + MACHINE_SOURCES, + MAX_INLINE_JSON_BYTES, + MAX_INLINE_UPLOAD_BYTES, + MAX_STORED_BYTES, +} from "@/lib/receipt-intake/intake-core"; +import { finalizeDisposition, leaseFence, verifyStoredCopy } from "@/lib/receipt-intake/stored-object"; +import { createRouteDeadline, type RouteDeadline } from "@/lib/quickbooks"; +import { + ARCHIVE_READABLE_STATES, + listReceiptIntakes, + serializeReceiptIntake, + withArchiveDownloadUrls, +} from "@/lib/receipt-intake/queries"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +/** + * THE ROUTE'S ONE ABSOLUTE DEADLINE, created at entry. + * + * `maxDuration = 30` is the whole request's budget, and every storage call now + * takes this rather than a fresh fifteen seconds of its own — three sequential + * calls with independent allowances could spend 45 seconds inside a handler + * the platform kills at 30. + */ +const ROUTE_BUDGET_MS = 27_000; + +/** + * Receipt Pipeline v2 intake — the ONE front door for every inbound receipt or + * check (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §3). + * + * POST accepts the mobile app (Bearer), staff (session), and the Apps Script + * forwarders (x-receipt-intake-secret). It does the cheap work only — hash, + * store, insert — and returns in well under a second, because a forwarder that + * times out re-POSTs and the whole point of `sourceRef` is that a replay is + * free. NO Gemini call happens here; the cron worker reads the row later. + * + * `/api/receipts/intake` is on the proxy's exact-match public bypass, so this + * handler is the sole auth boundary — see src/lib/receipt-intake/intake-auth.ts. + * + * Shadow-week gate: `dryRun` is captured PER ROW at intake time from + * RECEIPT_INTAKE_DRYRUN (default ON), so the flag a row was accepted under is + * a fact about that row rather than about whenever the worker next looks at + * it. At CUTOVER the worker's one-shot `requeueDryRunParked` deliberately + * flips the parked backlog to live in a single statement — see + * src/lib/receipt-intake/worker.ts. That is the ONLY thing that changes a + * row's dryRun after intake. + */ + +interface ParsedBody { + bytes: Buffer; + declaredMime: string; + fileName: string | null; + source: string; + sourceRef: string | null; + uploadId: string | null; + projectId: string | null; + costCodeId: string | null; + threadName: string | null; + /** The forwarder reporting that v1 already booked and archived this file. */ + archivedByV1: boolean; +} + +function bad(reason: string) { + return NextResponse.json({ ok: false, reason }, { status: 400 }); +} + +/** + * The inline path carries the file in the request body, which the platform caps + * around 4.5 MB — base64 JSON inflates it by a third on top. Anything bigger + * used to die at the edge with an opaque 413 that never reached this code, so + * the caller learned nothing. Say it plainly and name the path that works. + */ +/** + * 415, not 400: the caller's request was well-formed, we simply will never + * accept this format. Naming what IS accepted is the difference between a + * sender who fixes it and one who retries the same file forever. + */ +function unsupportedType(declared: string) { + const essence = declared.split(";")[0].trim().toLowerCase(); + return NextResponse.json( + { + ok: false, + error: "unsupported-file-type", + reason: essence === "text/plain" + ? "text receipts are not accepted: QuickBooks cannot attach a .txt, so it would be read and then stranded unbookable. Print or export it to PDF first." + : "the stored bytes are not a format QuickBooks can attach", + accepted: ACCEPTED_MIME_TYPES, + }, + { status: 415 }, + ); +} + +function tooLargeForInline(limit: number, encoding: "json" | "multipart") { + return NextResponse.json( + { + ok: false, + error: "payload-too-large", + reason: encoding === "json" + ? `JSON uploads are limited to ${limit} raw bytes; base64 inflates them by 4/3 and the serverless body cap is what actually rejects a larger one` + : `multipart uploads are limited to ${limit} bytes (serverless request-body cap)`, + maxInlineBytes: limit, + maxBytes: MAX_STORED_BYTES, + use: "POST /api/receipts/intake/start then PUT to the signed URL then POST /api/receipts/intake/{id}/finalize", + }, + { status: 413 }, + ); +} + +async function parseBody(req: Request): Promise { + const contentType = req.headers.get("content-type") ?? ""; + const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : null); + + if (contentType.includes("multipart/form-data")) { + let form: FormData; + try { + form = await req.formData(); + } catch { + return bad("invalid-multipart"); + } + const file = form.get("file"); + if (!(file instanceof File)) return bad("missing-file"); + // Multipart sends the bytes as-is, so it keeps the full 4 MiB. + if (file.size > MAX_INLINE_UPLOAD_BYTES) return tooLargeForInline(MAX_INLINE_UPLOAD_BYTES, "multipart"); + const bytes = Buffer.from(await file.arrayBuffer()); + if (bytes.length > MAX_INLINE_UPLOAD_BYTES) return tooLargeForInline(MAX_INLINE_UPLOAD_BYTES, "multipart"); + return { + bytes, + declaredMime: file.type || "application/octet-stream", + fileName: str(file.name), + source: String(form.get("source") ?? ""), + sourceRef: str(form.get("sourceRef")), + uploadId: str(form.get("uploadId")), + projectId: str(form.get("projectId")), + costCodeId: str(form.get("costCodeId")), + threadName: str(form.get("threadName")), + archivedByV1: form.get("archivedByV1") === "true", + }; + } + + let json: Record; + try { + json = await req.json(); + } catch { + return bad("invalid-json"); + } + const base64 = typeof json.fileBase64 === "string" ? json.fileBase64 : ""; + if (!base64) return bad("missing-file"); + // Cap BEFORE decoding: base64 is 4/3 the byte count, so this refuses an + // oversize payload without materialising it. + // Checked on the ENCODED length first, so an oversize payload is refused + // without materialising it. + if (base64.length > Math.ceil(MAX_INLINE_JSON_BYTES / 3) * 4 + 4) { + return tooLargeForInline(MAX_INLINE_JSON_BYTES, "json"); + } + const bytes = Buffer.from(base64, "base64"); + if (bytes.length === 0) return bad("missing-file"); + if (bytes.length > MAX_INLINE_JSON_BYTES) return tooLargeForInline(MAX_INLINE_JSON_BYTES, "json"); + return { + bytes, + declaredMime: typeof json.mimeType === "string" ? json.mimeType : "application/octet-stream", + fileName: str(json.fileName), + source: String(json.source ?? ""), + sourceRef: str(json.sourceRef), + uploadId: str(json.uploadId), + projectId: str(json.projectId), + costCodeId: str(json.costCodeId), + threadName: str(json.threadName), + // Strict === true: only an explicit boolean may mark a row as already + // booked by v1, because that flag is what excuses v2 from booking it. + archivedByV1: json.archivedByV1 === true, + }; +} + +export async function POST(req: Request) { + // ONE deadline for the whole request — see ROUTE_BUDGET_MS. + const deadline = createRouteDeadline(ROUTE_BUDGET_MS); + + const auth = await authenticateIntake(req, "ingest"); + if (!auth.ok) return auth.response; + + const parsed = await parseBody(req); + if (parsed instanceof NextResponse) return parsed; + + const mimeType = sniffMime(parsed.bytes, parsed.declaredMime); + if (!mimeType) return unsupportedType(parsed.declaredMime); + + // Computed BEFORE decideSource, not just before the insert: a session/ + // mobile caller with no uploadId is scoped by this hash, so decideSource + // needs it to mint a STABLE key rather than a random one. + const fileSha256 = createHash("sha256").update(parsed.bytes).digest("hex"); + + // PROVENANCE AND IDENTITY ARE NOT CALLER INPUT for a human, and the rules + // are decideSource()'s — THE SAME FUNCTION /start calls, not a copy. + // + // This block used to be a hand-written twin of it, and it had drifted in + // two ways that mattered: it checked the global MACHINE_SOURCES set instead + // of the sources THIS key owns (`auth.allowedSources`), and it validated + // only the namespace prefix, so `drive:` with an empty tail was accepted as + // a permanent, unique idempotency key that every later empty-tail forward + // then collided with. A forwarder reaching the two endpoints must not be + // able to tell them apart. + const decided = decideSource(auth, { + source: parsed.source, + sourceRef: parsed.sourceRef, + uploadId: parsed.uploadId, + checksum: fileSha256, + }); + if (!decided.ok) return bad(decided.reason); + const { source, sourceRef } = decided; + + // A session/Bearer caller may only file against a project they can reach. + // The secret caller is a trusted forwarder resolving the project from the + // Drive folder, and has no user to scope by. + if (auth.via === "session" && parsed.projectId) { + const allowed = await userCanAccessProject(auth.user, parsed.projectId); + if (!allowed) return NextResponse.json({ ok: false, reason: "forbidden" }, { status: 403 }); + } + + // A phase belongs to a job. Same gate as /start, AFTER the project + // authorization above: nothing downstream re-checks a cost code that was + // supplied when the row was created, so this is the only place to catch one + // that belongs to another job. + const badPhase = await authorizePhase(parsed.projectId, parsed.costCodeId, (project, code) => + isCostCodeAllowedForProject(prismaPhaseDataSource, project, code)); + if (badPhase) return NextResponse.json(badPhase.body, { status: badPhase.status }); + + const id = randomUUID(); + const ext = EXT_BY_MIME[mimeType] ?? "bin"; + const storagePath = `receipts/intake/${id}.${ext}`; + + // ROW FIRST, THEN THE OBJECT. + // + // Uploading first meant a replayed `sourceRef` had already written a second + // object into the private bucket before the insert failed, and the cleanup + // was best-effort. Worse, it made the two cases indistinguishable: a genuine + // forwarder retry and a REUSED sourceRef carrying different bytes both + // landed on the same P2002 and both got a cheerful 200, so a second, real + // receipt could be swallowed by the first one's row and never booked. + // + // Inserting first turns the unique index into the decision point, with + // `fileSha256` as the evidence: same bytes is a replay (200, the row you + // already have), different bytes is a caller bug (409) — and in the 409 + // case storage is never touched at all. + let created: { id: string; state: string; sourceRef: string; projectId: string | null; dryRun: boolean }; + try { + created = await prisma.receiptIntake.create({ + data: { + id, + source, + sourceRef, + // STAGING, not RECEIVED. Inserting first is what makes the + // unique index the decision point (see below), but it also + // publishes a claimable row whose object is not in the bucket + // yet — the worker would grab it, find nothing, and park a + // perfectly good receipt as "file-missing". STAGING is excluded + // from the claim predicate; the UPDATE after a successful + // upload is what actually hands the row to the worker. + state: "STAGING", + // Captured per row, never read from env again after this point. + dryRun: process.env.RECEIPT_INTAKE_DRYRUN !== "false", + projectId: parsed.projectId, + costCodeId: parsed.costCodeId, + createdById: auth.via === "session" ? auth.user.id : null, + // Only a shared-secret forwarder may assert this: it is the + // claim that v1 already put this document in the books, and it + // is what stops v2 from booking it at cutover. + archivedByV1: auth.via === "secret" ? parsed.archivedByV1 : false, + storagePath, + fileName: parsed.fileName, + mimeType, + fileSize: parsed.bytes.length, + fileSha256, + // `threadName` is accepted (the chat forwarder sends it) but not + // persisted: `memo` belongs to the READ step, which stores the + // check's handwritten memo line there. Phase 2 adds a column for + // the chat thread when the queue page needs to link back to it. + }, + select: { id: true, state: true, sourceRef: true, projectId: true, dryRun: true }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return respondToSourceRefConflict(auth, source, sourceRef, fileSha256, { + bytes: parsed.bytes, mimeType, storagePath, + }, deadline); + } + // A projectId/costCodeId that doesn't exist is the CALLER's mistake, so + // it must be a deterministic 400 — a 500 would make a forwarder retry a + // payload that can never succeed. + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return bad("unknown-project-or-cost-code"); + } + throw error; + } + + const supabase = getSupabase(); + if (!supabase) { + await prisma.receiptIntake.delete({ where: { id } }).catch(() => { /* best effort */ }); + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + // A THROW here is not the same as an error result — the SDK can reject + // before it ever reaches storage — but both leave a STAGING row pointing at + // an object that may not exist, so both clean it up. The caller's retry is + // then a clean insert rather than a conflict against a half-written row. + let uploadFailed: string | null = null; + try { + const stored = await uploadReceiptObject(storagePath, parsed.bytes, mimeType, { deadline }); + if (!stored) uploadFailed = "upload-failed"; + } catch (error) { + uploadFailed = error instanceof Error ? `${error.name}: ${error.message}` : "upload-threw"; + } + if (uploadFailed) { + // AMBIGUOUS. An upload error — especially a thrown one — does not tell + // us whether bytes landed: the write may have succeeded and the + // acknowledgement been lost. So the cleanup record is written BEFORE + // the row is deleted, while `storagePath` is still known to something. + // Delete first and the object (if any) is orphaned with nothing left + // pointing at it, invisible in a private bucket forever. + // + // A no-op cleanup for an upload that genuinely never landed is free; + // the sweeper's delete simply finds nothing. + try { + await recordPendingCleanup(storagePath, `upload-ambiguous:${uploadFailed}`.slice(0, 200)); + } catch { + // The record is the only thing that would remember this object. If + // it cannot be written, KEEP THE ROW: a STAGING row pointing at the + // path is the remaining way to find the bytes, and the sweeper will + // resolve it. Deleting now would orphan them with nothing left + // referencing them anywhere. + console.error("[receipts/intake] cleanup unrecordable; keeping the row as the pointer", storagePath); + return NextResponse.json( + { ok: false, reason: "storage-failed", id, retained: true }, + { status: 503 }, + ); + } + + // Row deletion is FENCED on state+path, NOT a bare delete by id. + // + // A concurrent replay carrying the same sourceRef can find this exact + // row via respondToSourceRefConflict, upload to ITS OWN path, and + // publish it — all while THIS request's upload is still failing. An + // unconditional `delete({ where: { id } })` would then destroy that + // now-RECEIVED row. The delete is a no-op unless the row is still + // exactly what THIS request created: still STAGING, still pointing at + // the path THIS request uploaded (or tried to upload) to. + // + // Failure to delete is still SURFACED, not swallowed: the caller's + // retry would otherwise hit a sourceRef conflict against a row it was + // told did not exist. + let deletedCount: number; + try { + const result = await prisma.receiptIntake.deleteMany({ + where: { id, state: "STAGING", storagePath }, + }); + deletedCount = result.count; + } catch (deleteError) { + console.error("[receipts/intake] row delete failed after an ambiguous upload", id, deleteError); + return NextResponse.json( + { ok: false, reason: "storage-failed", id, retained: true }, + { status: 503 }, + ); + } + if (deletedCount === 0) { + // Somebody else already moved this row on — most likely a + // concurrent replay published it via respondToSourceRefConflict's + // healing path. That is the outcome the caller wanted, even + // though THIS attempt's own upload failed, so report on what the + // row actually is now rather than a failure that no longer holds. + const current = await prisma.receiptIntake + .findUnique({ + where: { id }, + select: { id: true, state: true, sourceRef: true, projectId: true, dryRun: true }, + }) + .catch(() => null); + if (current?.state === "RECEIVED") { + return NextResponse.json({ ok: true, ...current, alreadyPublished: true }); + } + return NextResponse.json( + { ok: false, reason: "publish-conflict", id, state: current?.state ?? "gone" }, + { status: 409 }, + ); + } + console.error("[receipts/intake] upload failed", uploadFailed); + return NextResponse.json({ ok: false, reason: "storage-failed" }, { status: 503 }); + } + + return publishStagedRow(id, storagePath); +} + +/** + * STAGING -> RECEIVED. The one write that makes a row claimable. + * + * Split out because it must be RESUMABLE: if the upload lands and this UPDATE + * then fails (a connection reset between two round trips is not rare), the + * object exists and the row does not point at it, and nothing would ever fix + * that — the row is invisible to the worker's claim by design, so it would sit + * until the 15-minute sweeper wrongly declared its file missing. An identical + * retry finds the STAGING row, confirms the object really is there, and + * finishes the job. + */ +/** Upload bytes to the receipts bucket. Returns false on any failure. */ +const storeObject = ( + storagePath: string, + bytes: Buffer, + mimeType: string, + deadline: RouteDeadline | undefined, +) => uploadReceiptObject(storagePath, bytes, mimeType, { upsert: true, deadline }); + +async function publishStagedRow(id: string, expectStoragePath: string, expectState = "STAGING"): Promise { + try { + // EXACT-state, EXACT-path CAS. `update` by id and state alone would + // publish a row that had since moved on in a way that still matched + // `expectState` — a row re-armed onto a NEW upload path by a + // concurrent /start (or a heal in respondToSourceRefConflict) is + // still STAGING, but the object THIS caller verified is no longer + // the one the row points at. Publishing anyway would either point a + // RECEIVED row at bytes nobody uploaded, or (worse) race a second + // publisher onto the same state with two different ideas of which + // object is now canonical. Pinning storagePath makes that update + // match zero rows instead. + const { count } = await prisma.receiptIntake.updateMany({ + where: { id, state: expectState, storagePath: expectStoragePath }, + data: { state: "RECEIVED" }, + }); + if (count === 0) { + const current = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { state: true, storagePath: true }, + }); + // Somebody else won the publish race. If the winner's row now + // points somewhere OTHER than the path THIS call expected, the + // object THIS call verified/uploaded is unreferenced by any row — + // concurrent same-byte replays each upload to their own random + // path (see /start), so nothing else will ever find this one to + // clean it up. Do it now, before reporting the idempotent outcome + // the loser is about to return. + // + // AND THE FAILURE IS NOT SWALLOWED. deleteObjectOrRecord throws + // when it can neither delete the object nor record it, and this + // caller has no transaction to roll back — so the throw has to + // reach the client as a retryable 503 rather than be discarded on + // the way to a cheerful `alreadyPublished`. A forwarder deletes + // its only copy on that answer, and the bytes we just orphaned + // would be the ones nobody can find. + if (current?.storagePath && current.storagePath !== expectStoragePath) { + const remembered = await deleteObjectOrRecord( + expectStoragePath, + "orphaned-by-concurrent-publish", + ).then(() => true, () => false); + if (!remembered) { + return NextResponse.json( + { ok: false, reason: "storage-unavailable", retryable: true }, + { status: 503 }, + ); + } + } + // Somebody else published it; that is the outcome the caller wanted. + if (current?.state === "RECEIVED") { + return NextResponse.json({ ok: true, id, state: "RECEIVED", alreadyPublished: true }); + } + return NextResponse.json( + { ok: false, error: "publish-conflict", id, state: current?.state ?? "gone" }, + { status: 409 }, + ); + } + const published = await prisma.receiptIntake.findUnique({ + where: { id }, + select: { id: true, state: true, sourceRef: true, projectId: true, dryRun: true }, + }); + return NextResponse.json({ ok: true, ...(published ?? { id, state: "RECEIVED" }) }); + } catch (error) { + // The bytes ARE stored; only the publish failed. Leave the row in + // STAGING — 503 tells the caller to retry, and the retry resumes. + console.error("[receipts/intake] publish failed", error instanceof Error ? error.name : "error"); + return NextResponse.json({ ok: false, reason: "publish-failed", id, status: "staging" }, { status: 503 }); + } +} + +/** + * The sourceRef is already taken. Two very different situations: + * + * - SAME bytes -> the forwarder replayed. Return the row it already has; a + * non-200 would make it retry forever. + * - OTHER bytes -> the caller reused a key for a DIFFERENT document. Answering + * 200 would tell it the new receipt was accepted when nothing was stored, + * and that receipt would never be booked. 409, and storage stays untouched. + * + * Who may see the existing row is a separate question (a minted `web:` + * can only collide by accident, but a secret-authenticated caller that guessed + * a ref must not be able to enumerate other people's rows): only the row's own + * creator or a bookkeeping role gets fields back. + */ +async function respondToSourceRefConflict( + auth: Extract, + source: string, + sourceRef: string, + fileSha256: string, + /** The bytes this replay carried — used to HEAL a row whose object is gone. */ + payload: { bytes: Buffer; mimeType: string; storagePath: string }, + /** The REQUEST's deadline. A heal's upload draws on the same budget. */ + deadline: RouteDeadline, +): Promise { + const existing = await prisma.receiptIntake.findUnique({ + where: { sourceRef }, + select: { + id: true, state: true, source: true, sourceRef: true, projectId: true, + dryRun: true, fileSha256: true, createdById: true, storagePath: true, stateReason: true, + // The whole lease identity, because the heal's CAS pins it — see + // leaseFence. A refresh moves only the nonce and the expiry. + uploadLeaseVersion: true, uploadLeaseNonce: true, uploadUrlExpiresAt: true, + // Only for cleanupNotBefore: an inline row has no expiry, so its + // capability window is measured from createdAt. + createdAt: true, + }, + }); + // The row vanished between the failed insert and this read (a delete + // racing us). Tell the caller to retry rather than inventing an answer. + if (!existing) return NextResponse.json({ ok: false, reason: "conflict-retry" }, { status: 409 }); + + // AUTHORIZATION BEFORE ANY DETAIL, including on the mismatch branch. + // `existingId` is a real identifier for someone else's document; returning + // it to a caller who may not read the row turns a 409 into an oracle that + // confirms a guessed sourceRef and hands back a usable id. + // + // For a secret caller "may read" is narrower than "holds the secret": it + // may only see rows in ITS OWN namespace. The forwarders are separate + // scripts, and a chat forwarder guessing `drive:` should learn + // nothing about the Drive pipeline's rows. + const maySee = + auth.via === "secret" + ? MACHINE_SOURCES.has(existing.source) && + existing.source === source && + existing.sourceRef.startsWith(`${source}:`) + : existing.createdById === auth.user.id || STAFF_READ_ROLES.includes(auth.user.role); + + if (!maySee) { + // No fields at all: an id or a state would still confirm the row exists. + return NextResponse.json({ ok: false, error: "sourceRef-conflict" }, { status: 409 }); + } + + if (existing.fileSha256 !== fileSha256) { + return NextResponse.json( + { ok: false, error: "sourceRef-conflict", existingId: existing.id }, + { status: 409 }, + ); + } + + // SAME BYTES. Before promising anything, confirm the document is actually + // in the bucket — for EVERY state, not just STAGING. + // + // Checking only STAGING left a hole: once the stale-row sweep flipped an + // orphan to NEEDS_REVIEW/file-missing, this replay returned a cheerful 200 + // and the forwarder could delete its only copy of a receipt we did not + // have. The state a row happens to be parked in says nothing about whether + // its bytes exist. + // AND PRESENCE IS NOT THE SAME QUESTION AS CORRECTNESS. + // + // Existence alone still authorised the delete, on the strength of bytes + // nobody had looked at since they were sealed — so an object REPLACED after + // publication (an upsert URL reused, a restore that put back a different + // version, a storage-side fault) was laundered into "we have your receipt", + // and the last good copy went with it. `existing.fileSha256` is the hash + // this row was published with, and on this branch it provably equals the + // hash of the payload in this very request; the stored bytes must still + // hash to it. ONE rule, shared with /finalize (stored-object.ts), so the two + // replay paths cannot come to disagree about what "we already have it" means. + // + // A cheap metadata probe still runs first inside it: this path runs on every + // replay and the object may be 8 MiB, so an orphan never pays for a + // download. A TRANSIENT answer is not evidence of absence — healing on it + // would overwrite a document that is really there — so it is answered 503 + // and the forwarder retries with its copy intact. + const held = await verifyStoredCopy(existing.storagePath, existing.fileSha256, deadline); + if (!held.ok && held.kind === "transient") { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + if (!held.ok && held.kind === "content-mismatch") { + // NOT healed. A re-upload is exactly how bytes get replaced, so + // overwriting on a mismatch would let a replay launder the swap. Never a + // 2xx either: the sender keeps its copy. The row is left as it is for + // the worker's `content-changed` park and the sweeper to act on. + return NextResponse.json( + { + ok: false, + error: "content-mismatch", + reason: "the stored document is not the one this row was published with; keep your copy and escalate", + retryable: false, + id: existing.id, + state: existing.state, + }, + { status: 409 }, + ); + } + if (!held.ok) { + // The caller just handed us the bytes again, so the orphan is fixable: + // store them and republish. This is the retry HEALING the row rather + // than merely reporting on it. + // Recovery is restricted to the two reasons a later, correct upload can + // actually fix. "Any NEEDS_REVIEW row" was far too broad: a row parked + // for a vendor mismatch, a zero total, or a QBO fault would be dragged + // back to RECEIVED and re-read, discarding the decision a human had + // already made about it. + // ONE list, shared with /finalize (stored-object.ts). Two copies of + // "which parks a re-upload may clear" is how the two publishers come to + // disagree about whether a human's decision can be overwritten. + const healable = finalizeDisposition(existing) === "publish"; + if (healable) { + // CLAIMED BEFORE IT IS WRITTEN — the same phase-A rule the publish + // uses, for the same reason. + // + // `uploadReceiptObject` returns false for a refusal AND for an + // ambiguous outcome: a write storage may well have accepted before + // the response was lost. Returning 503 on that without recording + // anything left those bytes in a private bucket with no row + // pointing at them, no event remembering them and no sweep looking + // for them — the row still points at its OLD path, so the + // stale-STAGING sweep never sees this one. + // + // The intent is committed first, in its own short transaction, and + // carries a lease so the sweeper leaves the path alone while this + // request is still using it. A heal that succeeds cancels it in the + // transaction that repoints the row; a heal that fails, ambiguously + // or otherwise, simply leaves it for the sweeper. + let healIntentId: string; + try { + healIntentId = await claimObjectPath(payload.storagePath, cleanupNotBefore(existing)); + } catch { + // Writing an object we could not first promise to clean up IS + // the leak. Nothing is uploaded. + return NextResponse.json( + { ok: false, reason: "storage-unavailable", retryable: true }, + { status: 503 }, + ); + } + const healed = await storeObject(payload.storagePath, payload.bytes, payload.mimeType, deadline); + if (!healed) { + // AMBIGUOUS: storage may hold the bytes. The intent stands and + // the sweeper collects them once its lease lapses, rechecking + // live references first. + return NextResponse.json({ ok: false, error: "storage-failed" }, { status: 503 }); + } + // The SAME fence /finalize publishes under: exact state, exact + // reason, unclaimed. Losing this race means somebody moved the row + // while we were uploading, so the object we just wrote is + // unreferenced — clean it up rather than orphan it. + // ONE SHORT TRANSACTION, with the upload already done: the repoint + // and the cancellation of its intent land together or neither does. + const { count } = await inShortTx(async tx => { + const moved = await tx.receiptIntake.updateMany({ + // leaseFence: the heal REPOINTS the row at bytes this request + // uploaded, and a /start that reissued the client's URL in the + // meantime moves neither the state, the reason nor the version. + where: { id: existing.id, ...leaseFence(existing) }, + data: { storagePath: payload.storagePath, state: "RECEIVED", stateReason: null, nextRetryAt: null }, + }); + // Referenced from this instant, so the intent goes with it. + if (moved.count > 0) await resolveCanonicalIntent(tx, healIntentId); + return moved; + }); + if (count === 0) { + // Same rule as the publish-race drop above: this branch has no + // transaction of its own, so a cleanup that could neither + // delete nor record must surface as a retryable 503 instead of + // being lost inside a 409. + if (payload.storagePath !== existing.storagePath) { + const remembered = await deleteObjectOrRecord( + payload.storagePath, + "heal-lost-race", + ).then(() => true, () => false); + if (!remembered) { + return NextResponse.json( + { ok: false, reason: "storage-unavailable", retryable: true }, + { status: 503 }, + ); + } + } + return NextResponse.json( + { ok: false, error: "publish-conflict", id: existing.id }, + { status: 409 }, + ); + } + return NextResponse.json({ + ok: true, recovered: true, id: existing.id, state: "RECEIVED", + sourceRef: existing.sourceRef, projectId: existing.projectId, dryRun: existing.dryRun, + }); + } + // A booked/archived row with no object is not something a replay may + // rewrite. Retryable failure, never a 2xx. + return NextResponse.json( + { + ok: false, + error: "object-missing", + reason: "this sourceRef exists but its stored document is gone; escalate", + id: existing.id, + state: existing.state, + }, + { status: 409 }, + ); + } + + // The object is there AND it is this document. A STAGING row means the + // previous request uploaded successfully and only its publish UPDATE + // failed — finish it. + if (existing.state === "STAGING") return publishStagedRow(existing.id, existing.storagePath); + + return NextResponse.json({ + ok: true, + alreadyReceived: true, + id: existing.id, + state: existing.state, + sourceRef: existing.sourceRef, + projectId: existing.projectId, + dryRun: existing.dryRun, + }); +} + +/** + * Staff queue read, and the nightly Apps Script archive mirror's source of + * work (§6 polls `?state=BOOKED` with the shared secret). The proxy bypass + * means this handler enforces the role itself — a session user without a + * bookkeeping role gets 403, not a redirect. + */ +export async function GET(req: Request) { + // A secret caller here is the ARCHIVE mirror. The ingest forwarders hold a + // different key and are refused with 403 — a script that only copies files + // to Drive has no business enumerating the queue, and vice versa. + const auth = await authenticateIntake(req, "archive"); + if (!auth.ok) return auth.response; + if (auth.via === "session" && !STAFF_READ_ROLES.includes(auth.user.role)) { + return NextResponse.json({ ok: false, reason: "forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const state = url.searchParams.get("state"); + + // The secret caller is the archive mirror and nothing else. It reads only + // the two states it acts on, and only the columns it needs — see + // RECEIPT_INTAKE_ARCHIVE_SELECT. Staff sessions are unaffected. + const archiveOnly = auth.via === "secret"; + if (archiveOnly && (!state || !ARCHIVE_READABLE_STATES.has(state))) { + return NextResponse.json( + { ok: false, reason: "state-not-allowed", allowed: [...ARCHIVE_READABLE_STATES] }, + { status: 400 }, + ); + } + + const rows = await listReceiptIntakes({ + state, + projectId: archiveOnly ? null : url.searchParams.get("projectId"), + take: url.searchParams.get("take") ? Number(url.searchParams.get("take")) : null, + archiveOnly, + }); + + if (archiveOnly) { + // The mirror needs to FETCH each file and NAME it. It holds no service + // key and cannot read the private bucket, so every row carries a + // short-lived signed URL plus the project name the filename is built + // from. + const withUrls = await withArchiveDownloadUrls( + rows as Array<{ storagePath: string; project?: { name: string } | null }>, + ); + return NextResponse.json({ ok: true, rows: withUrls.map(serializeReceiptIntake) }); + } + + return NextResponse.json({ ok: true, rows: rows.map(serializeReceiptIntake) }); +} diff --git a/src/app/api/receipts/intake/start/route.ts b/src/app/api/receipts/intake/start/route.ts new file mode 100644 index 000000000..f62eff810 --- /dev/null +++ b/src/app/api/receipts/intake/start/route.ts @@ -0,0 +1,906 @@ +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { userCanAccessProject } from "@/lib/mobile-auth"; +import { authenticateIntake } from "@/lib/receipt-intake/intake-auth"; +import { ACCEPTED_MIME_TYPES, EXT_BY_MIME } from "@/lib/receipt-intake/file-type"; +import { decideSource, MAX_STORED_BYTES } from "@/lib/receipt-intake/intake-core"; +import { cleanupNotBefore, uploadLeaseExpiry } from "@/lib/receipt-intake/worker"; +import { authorizePhase } from "@/lib/receipt-intake/late-fields"; +import { + finalizeDisposition, + leaseFence, + type ObservedRow, + uploadPathFor, + verifyStoredCopy, +} from "@/lib/receipt-intake/stored-object"; +import { + discardUnresumedLease, + issuedLeaseIsCurrent, + newLeaseNonce, + reuseLiveLease, +} from "@/lib/receipt-intake/upload-lease"; +import { createReceiptUploadUrl } from "@/lib/receipt-intake/bucket"; +import { queueObjectCleanup } from "@/lib/receipt-intake/storage-cleanup"; +import { isCostCodeAllowedForProject } from "@/lib/project-phases"; +import { prismaPhaseDataSource } from "@/lib/project-phases-db"; +import { createRouteDeadline, type RouteDeadline } from "@/lib/quickbooks"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 30; + +/** + * THE ROUTE'S ONE ABSOLUTE DEADLINE, created at entry. + * + * `maxDuration = 30` is the whole request's budget, and every storage call now + * takes this rather than a fresh fifteen seconds of its own — three sequential + * calls with independent allowances could spend 45 seconds inside a handler + * the platform kills at 30. + */ +const ROUTE_BUDGET_MS = 27_000; + +/** + * THE /start SUCCESS RESPONSE IS A UNION, and `kind` is what tells them apart. + * + * The spec said every success carries an upload URL. It does not, and never + * did: a sourceRef whose document is already held and verified has nothing to + * upload, so that branch answers `alreadyReceived` with no `uploadUrl` and no + * `uploadLease`. A mobile client that assumed the URL was always there read + * `undefined` and had no way to tell that apart from a malformed response. + * + * Naming the two members, in the route and in the spec, is the fix: a client + * switches on `kind` and the compiler (theirs and ours) enumerates the cases. + * `ok: true` alone never implies there is somewhere to PUT bytes. + */ +const UPLOAD = "upload" as const; +const SETTLED = "settled" as const; + +/** There is somewhere to PUT the bytes, and a lease to echo back at /finalize. */ +export interface StartUploadResponse { + ok: true; + kind: typeof UPLOAD; + id: string; + /** Where to PUT. Scoped to ONE path, derived here and bound to the row. */ + uploadUrl: string; + token: string; + storagePath: string; + /** + * The generation this URL was issued under. /finalize REQUIRES it back and + * answers 409 `lease-stale` to anything else -- see the finalize route. + */ + uploadLease: string; + maxBytes: number; + sourceRef?: string; + state?: string; + /** True when this row already existed and its lease was reused or renewed. */ + resumed?: boolean; + /** True when a recoverable park was re-armed rather than freshly created. */ + recovered?: boolean; +} + +/** Nothing to upload: the document is already held, and verified byte-for-byte. */ +export interface StartSettledResponse { + ok: true; + kind: typeof SETTLED; + alreadyReceived: true; + id: string; + state: string; +} + +export type StartResponse = StartUploadResponse | StartSettledResponse; + +/** + * EVERY /start SUCCESS GOES THROUGH HERE. + * + * The union is only worth declaring if it is checked. Routed through this + * helper, a branch that forgets `kind`, or returns an upload response with no + * `uploadLease`, is a compile error rather than a contract the mobile app + * discovers at runtime. + */ +const startOk = (body: StartResponse) => NextResponse.json(body); + +/** + * Step 1 of the two-step upload: reserve the row, hand back a signed URL. + * + * The single-shot POST /api/receipts/intake puts the file in the REQUEST BODY, + * and a serverless body is not a 15 MB pipe — the platform caps it around + * 4.5 MB and base64 inflates the payload by a third on top. Phone photos + * routinely exceed that, and they were failing at the edge with an opaque 413 + * that never reached our code. This path never carries the bytes at all: the + * client PUTs them straight to Supabase, which has no such limit. + * + * The signed URL is scoped to ONE path, which is derived here and bound to the + * row — the client cannot choose where its bytes land, so it cannot overwrite + * another receipt's object or write outside the intake prefix. + * + * No object exists yet, so the row is STAGING and the worker cannot see it. + * /finalize is what publishes it. + */ +export async function POST(req: Request) { + // ONE deadline for the whole request — see ROUTE_BUDGET_MS. + const deadline = createRouteDeadline(ROUTE_BUDGET_MS); + + const auth = await authenticateIntake(req, "ingest"); + if (!auth.ok) return auth.response; + + let body: Record; + try { + body = await req.json(); + } catch { + return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 }); + } + const str = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : null); + + // REFUSED BEFORE A ROW EXISTS. A 400 after creating the row left a STAGING + // row for a document we will never accept, which the sweeper then has to + // reason about. 415, not 400: the request is well-formed, the format is + // simply one QuickBooks cannot attach. + // + // The declared mime only picks the extension; /finalize re-derives the real + // type from the STORED BYTES, so a lie costs the caller its upload. + const mimeType = String(body.mimeType ?? "").split(";")[0].trim().toLowerCase(); + const ext = EXT_BY_MIME[mimeType]; + if (!ext) { + return NextResponse.json( + { + ok: false, + error: "unsupported-file-type", + reason: mimeType === "text/plain" + ? "text receipts are not accepted: QuickBooks cannot attach a .txt. Print or export it to PDF first." + : "that format is not one QuickBooks can attach", + accepted: ACCEPTED_MIME_TYPES, + }, + { status: 415 }, + ); + } + + // The client's own hash of what it is ABOUT to upload. Persisted, because + // the two-step flow hands the bytes straight to storage: without it a + // reused sourceRef carrying a DIFFERENT document is indistinguishable from + // an honest retry, and /finalize would attach one receipt's bytes to + // another receipt's identity. + // REQUIRED, not optional. + // + // It is the only thing that gives this row an identity before any bytes + // exist. Without it a reused sourceRef is indistinguishable from an honest + // retry, so /start would happily hand out an upsert URL pointed at another + // document's object — and the swap would only surface at /finalize, by + // which point the original bytes are gone. + const expectedSha256 = typeof body.sha256 === "string" ? body.sha256.trim().toLowerCase() : ""; + if (!/^[0-9a-f]{64}$/.test(expectedSha256)) { + return NextResponse.json( + { + ok: false, + reason: "missing-sha256", + detail: "sha256 of the bytes you are about to upload is required (64 lowercase hex chars)", + }, + { status: 400 }, + ); + } + + const declaredSize = Number(body.fileSize); + if (Number.isFinite(declaredSize) && declaredSize > MAX_STORED_BYTES) { + return NextResponse.json({ ok: false, reason: "file-too-large", maxBytes: MAX_STORED_BYTES }, { status: 413 }); + } + + const decided = decideSource(auth, { + source: str(body.source), + sourceRef: str(body.sourceRef), + uploadId: str(body.uploadId), + // Already validated above (64 lowercase hex): the client's own hash of + // what it is about to upload is exactly the checksum a no-uploadId + // session caller needs a STABLE key derived from. + checksum: expectedSha256, + }); + if (!decided.ok) return NextResponse.json({ ok: false, reason: decided.reason }, { status: 400 }); + + const projectId = str(body.projectId); + if (auth.via === "session" && projectId) { + if (!(await userCanAccessProject(auth.user, projectId))) { + return NextResponse.json({ ok: false, reason: "forbidden" }, { status: 403 }); + } + } + + // THE PHASE IS CHECKED HERE OR NOWHERE. + // + // A costCodeId supplied at /start used to be stored unchecked, and nothing + // downstream re-checks it: /finalize only authorizes the fields the + // FINALIZE call carries, so a client could smuggle a phase from another job + // past every gate simply by omitting it at finalize. The FK gives a 400 for + // a cost code that does not exist at all, which is a different question + // from whether it belongs to this job. + // + // AFTER the project authorization above, never before: validating against a + // project the caller cannot reach would answer questions about somebody + // else's job. + const costCodeId = str(body.costCodeId); + const badPhase = await authorizePhase(projectId, costCodeId, (project, code) => + isCostCodeAllowedForProject(prismaPhaseDataSource, project, code)); + if (badPhase) return NextResponse.json(badPhase.body, { status: badPhase.status }); + + const id = randomUUID(); + // Lease 1 from the outset: the version is part of the path, so there is no + // "version 0" object to confuse with a resumed upload later. + const storagePath = uploadPathFor(id, 1, ext); + // Held in a const, not re-derived: it is the value written to the row AND + // the value the discard below CASes on. Calling uploadLeaseExpiry() twice + // would compare a fresh instant against the stored one and never match. + const leaseExpiresAt = uploadLeaseExpiry(); + // The generation THIS request stamps on the lease. The expiry alone could + // not identify it — a concurrent retry's reuse writes "now + 2h" too, and + // the two can be the same millisecond — so the discard CAS pins this + // instead. See discardUnresumedLease. + const leaseNonce = newLeaseNonce(); + + let created: { id: string; sourceRef: string; state: string }; + try { + created = await prisma.receiptIntake.create({ + data: { + id, + source: decided.source, + sourceRef: decided.sourceRef, + state: "STAGING", + dryRun: process.env.RECEIPT_INTAKE_DRYRUN !== "false", + projectId, + costCodeId, + createdById: auth.via === "session" ? auth.user.id : null, + // Forwarder-only, same as the single-shot path: this is the + // claim that v1 already booked the document. + archivedByV1: auth.via === "secret" && body.archivedByV1 === true, + storagePath, + fileName: str(body.fileName), + mimeType, + fileSize: 0, + // Unknown until the bytes land. /finalize recomputes it FROM + // STORAGE and writes the real value; a client-declared hash is + // never trusted as the stored one — only checked against it. + fileSha256: "", + expectedSha256, + // The promise this response makes: until then the client's URL + // works, so nothing may declare the object missing or reject + // the row for what is at that path. + uploadUrlExpiresAt: leaseExpiresAt, + uploadLeaseVersion: 1, + uploadLeaseNonce: leaseNonce, + }, + select: { id: true, sourceRef: true, state: true }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + // Same sourceRef: hand back the row already in flight so a retrying + // client resumes rather than orphaning a second object. + const existing = await prisma.receiptIntake.findUnique({ + where: { sourceRef: decided.sourceRef }, + select: { + id: true, sourceRef: true, state: true, stateReason: true, storagePath: true, + createdById: true, expectedSha256: true, fileSha256: true, + uploadLeaseVersion: true, uploadUrlExpiresAt: true, + // The generation the fences pin — see leaseFence. + uploadLeaseNonce: true, + // Only for cleanupNotBefore: an inline row has no expiry, + // and its capability window is measured from createdAt. + createdAt: true, + }, + }); + if (!existing) return NextResponse.json({ ok: false, reason: "conflict-retry" }, { status: 409 }); + const maySee = + auth.via === "secret" || + existing.createdById === auth.user.id || + auth.user.role === "ADMIN"; + if (!maySee) return NextResponse.json({ ok: false, error: "sourceRef-conflict" }, { status: 409 }); + + // A ROW THE SWEEPER PARKED AS RECOVERABLE GETS A NEW URL, NOT + // "alreadyReceived". + // + // file-missing and sha-mismatch both mean the bytes we hold RIGHT + // NOW are not the document (or are not there at all), and the row + // never published. Answering alreadyReceived told the forwarder we + // had a receipt we did not have — and it deletes its only copy on + // that answer — leaving the row parked forever with nothing to + // recover from. So the upload is re-armed instead: a fresh signed + // URL, and the sha the caller is about to upload becomes the + // expected one. + // + // BUT a row can be "recoverable" and still remember a REAL, + // previously verified identity — file-missing in particular is + // reached from a row that was already published (RECEIVED) and + // later found to have lost its object; its fileSha256 records the + // document that was actually published, not a stale guess. A + // recovery must not silently rebind that identity to different + // bytes: skipping the check entirely (as before) let a receipt + // published once, then physically lost, be "recovered" with an + // entirely unrelated document. Only a row with NO recorded hash at + // all (nothing to protect) may rearm without proving identity. + // + // "RECORDED" MEANS `fileSha256`, AND ONLY `fileSha256`. + // + // That column is written by the seal, from the bytes actually in + // the bucket — it is the one hash this system has ever verified, + // and the only one that can describe a document a human or + // QuickBooks has seen. `expectedSha256` is the opposite: a promise + // a client made about bytes it was ABOUT to upload, and on a + // recoverable park that promise is precisely what was never kept. + // + // OR-ing the two in bricked the recovery it was guarding. Both + // recoverable parks are reachable from STAGING, where `fileSha256` + // is "" — so the announced-but-unuploaded hash became the identity + // to protect, and a forwarder coming back with a corrected hash + // (a re-scanned Drive file, a recomputed digest) got 409 forever on + // a sourceRef that had never held a document at all. Nothing can + // be overwritten by narrowing it: a rearm writes to a NEW lease + // path, clears `fileSha256`, and leaves the row parked until + // /finalize verifies the bytes that actually land. + const recoverable = existing.state !== "STAGING" + && finalizeDisposition(existing) === "publish"; + if (recoverable) { + const verifiedSha = (existing.fileSha256 || "").toLowerCase(); + if (verifiedSha && verifiedSha !== expectedSha256) { + return NextResponse.json( + { + ok: false, + error: "sourceRef-conflict", + reason: "this sourceRef's previously recorded document has a different hash; it cannot be rebound to different bytes", + existingId: existing.id, + }, + { status: 409 }, + ); + } + // A LIVE LEASE IS NOT INVALIDATED BY A RETRY HERE EITHER. + // + // The re-arm below is destructive by design — new version, new + // path, the old object deleted — and it used to run on EVERY + // /start for a recoverable row, including one whose signed URL + // was still live. Two retries for the same parked sourceRef (a + // forwarder's own retry policy, a double-tap) therefore raced: + // the second deleted the object the first was about to PUT its + // bytes to, and the first request's URL pointed at nothing. + // Same failure the STAGING path was fixed for; the rule is one + // rule now (see reuseLiveLease). + // + // The re-arm's identity writes still happen, because a recovery + // may legitimately arrive with a CORRECTED expected hash — they + // just land on the SAME path and the SAME lease version. + // THE IDENTITY WRITES NO LONGER RIDE ALONG. `expectedSha256` + // and `mimeType` are part of a LIVE lease's identity, and + // passing them here is how a second caller came to overwrite + // the announced hash while keeping the same generation: two + // callers, one lease, two documents, and only the last hash + // could finalize. They are now compared instead (a + // disagreement is a 409), and only the recovery's own state + // writes are extended through. + const keptRecovery = await reuseLiveLease(existing, ext, leaseDepsFor(deadline), { + fileSha256: "", + fileSize: 0, + nextRetryAt: null, + }, expectedSha256); + if (keptRecovery) { + if (keptRecovery.kind === "storage-unavailable") { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + if (keptRecovery.kind === "identity-conflict") { + return leaseIdentityConflict( + existing.id, + keptRecovery.field, + keptRecovery.expiresAt, + ); + } + if (keptRecovery.kind === "conflict") return leaseConflict(existing.id); + return startOk({ + ok: true, + kind: UPLOAD, + resumed: true, + recovered: true, + id: existing.id, + state: existing.state, + maxBytes: MAX_STORED_BYTES, + ...keptRecovery.signed, + }); + } + + // THE ROW MOVES FIRST, THEN THE URL IS SIGNED. + // + // The claim on the lease is made in ONE checked update: the + // version goes up, the expiry is refreshed, and the row is + // re-pointed at the path that version names. Signing first and + // writing after left a window where a sweep could reject the + // row for the OLD upload while a URL for the new one was + // already in the client's hands. + const nextLease = existing.uploadLeaseVersion + 1; + const retryPath = uploadPathFor(existing.id, nextLease, ext); + // ONE TRANSACTION: the repath and the OLD path's cleanup entry. + // + // The move orphans the previous object, and the queue entry is + // the only thing that will remember it. Writing them separately + // meant a transient database failure on the second left bytes + // no row referenced and no sweep would ever look at — silently, + // because the failure was swallowed. Now they commit together + // or the row never moves. + // Hoisted so the response can echo it: /finalize requires the + // generation its URL was issued under. + const rearmedLease = newLeaseNonce(); + const repathed = await repathWithCleanup( + existing, + { + storagePath: retryPath, + expectedSha256, + uploadUrlExpiresAt: uploadLeaseExpiry(), + uploadLeaseVersion: nextLease, + // Same generation stamp every adoption writes, so a + // concurrent discard can never mistake this row for + // the lease it created. + uploadLeaseNonce: rearmedLease, + // The stored hash is what /finalize verifies against. + // Whatever was recorded describes bytes that are gone + // or were never right. + fileSha256: "", + mimeType, + fileSize: 0, + nextRetryAt: null, + }, + retryPath, + "start-rearmed-repath", + ); + if (repathed === "unavailable") { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + if (repathed === "conflict") { + return leaseConflict(existing.id); + } + // THE OLD OBJECT IS UNREFERENCED THE INSTANT THE CAS LANDS — + // so it is cleaned up here, BEFORE the signing that may fail, + // rather than after it. + // + // The row moves first on purpose (see above), which means the + // previous path is orphaned whether or not a URL is ever + // issued for the new one. Doing the cleanup only on the happy + // path left every 503 below leaking an object nothing + // referenced and nothing remembered: not the row (it points + // elsewhere now), not the stale-STAGING sweep (it looks at + // rows), not the cleanup queue (nobody had recorded it). + // deleteObjectOrRecord is exactly the guarded path for this — + // it deletes, and records a pending cleanup when the delete + // fails. + const rearmed = await signUpload(retryPath, { deadline }); + if (!rearmed) { + // The row keeps the NEW path and a live expiry, so the + // caller's retry lands in reuseLiveLease and is handed a + // URL over that same path. Nothing is orphaned by the + // failure itself. + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + // THE LEASE IS RE-READ BEFORE IT IS RETURNED. The CAS above + // proved this generation was ours when we wrote it; the sign + // is a network round trip, and a concurrent /start can adopt + // or repath the row while it is in flight. Returning the nonce + // we simply happened to generate would hand back a lease + // /finalize refuses -- see issuedLeaseIsCurrent. + if (!await issuedLeaseIsCurrent( + existing.id, + { storagePath: retryPath, uploadLease: rearmedLease }, + reloadLeaseRow, + )) return leaseConflict(existing.id); + return startOk({ + ok: true, + kind: UPLOAD, + resumed: true, + recovered: true, + id: existing.id, + state: existing.state, + maxBytes: MAX_STORED_BYTES, + ...rearmed, + uploadLease: rearmedLease, + }); + } + + // IDENTITY MUST BE PROVEN BEFORE AN UPSERT URL IS REISSUED. + // + // The URL is `upsert: true` so a caller can replace its OWN partial + // upload — which is exactly why handing one out for an existing path + // requires proof this is the same document. A mismatching or + // unknown-identity request would otherwise get a URL that + // overwrites receipt A with receipt B, and only /finalize would + // notice, by which point A's bytes are gone. + const knownSha = (existing.fileSha256 || existing.expectedSha256 || "").toLowerCase(); + if (!knownSha || knownSha !== expectedSha256) { + return NextResponse.json( + { + ok: false, + error: "sourceRef-conflict", + reason: knownSha + ? "this sourceRef already holds a different document" + : "this sourceRef exists with no recorded hash; identity cannot be proven", + existingId: existing.id, + }, + { status: 409 }, + ); + } + + if (existing.state !== "STAGING") { + // "We already have it" has to be TRUE: the forwarder deletes its + // only copy on this answer. + // + // PRESENCE WAS NOT TRUTH. This branch used to ask storage for a + // SIZE and answer alreadyReceived on anything that came back, so + // the sender was authorised to destroy its copy on the strength + // of bytes nobody had looked at since they were sealed. An + // object replaced or corrupted after publication — the upload + // URL is `upsert: true`, a restore can put back a different + // version, storage can fault — was laundered into "we hold your + // receipt" and the last good copy went with it. The row's + // `fileSha256` is the only hash this system has ever verified; + // the stored bytes must still hash to it. + // + // ONE rule, shared with the other two replay paths (POST + // /intake and /intake/{id}/finalize, see stored-object.ts), so + // the three cannot come to disagree about what "we already have + // it" means. The cheap metadata probe still runs first inside + // it, so the common orphan case never pays for a download. + const held = await verifyStoredCopy(existing.storagePath, existing.fileSha256, deadline); + if (!held.ok && held.kind === "transient") { + // Storage could not answer. That is never evidence about the + // bytes, so it is never a verdict: the sender retries with + // its copy intact. + return NextResponse.json( + { ok: false, reason: "verify-unavailable", retryable: true }, + { status: 503 }, + ); + } + if (!held.ok && held.kind === "content-mismatch") { + // NOT healed, and never a 2xx. A re-upload is exactly how + // bytes get replaced, so healing here would let a replay + // launder the swap. The row is left exactly as it is for the + // worker's `content-changed` park and the sweeper to act on. + return NextResponse.json( + { + ok: false, + error: "content-mismatch", + reason: "the stored document is not the one this row was published with; keep your copy and escalate", + retryable: false, + existingId: existing.id, + state: existing.state, + }, + { status: 409 }, + ); + } + if (!held.ok) { + // A settled row with no object. Recovering it is not this + // endpoint's job — /start hands out an upload URL for rows + // that are still STAGING or recoverably parked, and dragging + // a BOOKED row back would rewrite a receipt behind a + // Purchase. A 409 the sender can act on, never a 2xx. + return NextResponse.json( + { + ok: false, + error: "file-missing", + reason: "this sourceRef exists but its stored document is gone; escalate", + retryable: true, + existingId: existing.id, + state: existing.state, + }, + { status: 409 }, + ); + } + // THE OTHER MEMBER OF THE RESPONSE UNION. There is nothing to + // upload -- the document is already held and verified -- so + // this branch deliberately carries no uploadUrl and no + // uploadLease, and says so in `kind`. A client that keys off + // `kind` cannot mistake it for an upload response and go + // looking for a URL that was never going to be there. + return startOk({ + ok: true, + kind: SETTLED, + alreadyReceived: true, + id: existing.id, + state: existing.state, + }); + } + // A LIVE LEASE IS NOT INVALIDATED BY A RETRY. Same rule, same + // helper, as the recoverable re-arm above. + const kept = await reuseLiveLease( + existing, + ext, + leaseDepsFor(deadline), + {}, + expectedSha256, + ); + if (kept) { + if (kept.kind === "storage-unavailable") { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + if (kept.kind === "identity-conflict") { + return leaseIdentityConflict(existing.id, kept.field, kept.expiresAt); + } + if (kept.kind === "conflict") return leaseConflict(existing.id); + return startOk({ + ok: true, kind: UPLOAD, resumed: true, id: existing.id, + maxBytes: MAX_STORED_BYTES, ...kept.signed, + }); + } + + // A RESUME IS A NEW LEASE, taken BEFORE the URL is signed and in one + // checked update. Without the version bump the sweep and the client + // are talking about the same path, so a sweep that started before + // this call could still reject the upload it is now waiting for. + // Reached only once the previous lease has EXPIRED (or this is a + // fresh STAGING row with no lease yet) — an expired lease is fair + // game to invalidate, since nothing live can still be relying on it. + const nextLease = existing.uploadLeaseVersion + 1; + const resumePath = uploadPathFor(existing.id, nextLease, ext); + // ONE TRANSACTION, same rule as the re-arm above: the repath + // orphans the previous object and the queue entry is all that will + // remember it, so the two commit together or neither does. Also + // BEFORE the signing — the CAS re-points the row whatever the + // signer does next, so cleaning up only on the happy path left + // every 503 below leaking an object nothing referenced. + // + // THE COMPLETE LEASE IDENTITY, not state + version. + // + // `reuseLiveLease` extends a lease over the SAME path at the SAME + // version, writing only the nonce and the expiry — so a fence of + // {state, version} still matched a row another request had just + // refreshed. Around the expiry boundary that is a live race: A + // reads the lease a millisecond before it lapses and extends it; B + // reads it a millisecond after, finds it dead, and falls through to + // here — where its CAS matched anyway, overwrote A's refresh, + // repathed the row and queued A's path for deletion. A had already + // returned a working-looking URL to a path nothing now references. + // `state` is provably "STAGING" here (the guard at :410 returned + // for everything else), so this is strictly stronger, never + // different. A lost CAS answers the same retryable 409 the re-arm + // branch does, and the retry re-reads and re-decides rather than + // orphaning anything — repathWithCleanup rolls back as one. + // Hoisted for the same reason as the re-arm's: the response echoes it. + const resumedLease = newLeaseNonce(); + const resumedRepath = await repathWithCleanup( + existing, + { + storagePath: resumePath, + uploadLeaseVersion: nextLease, + uploadUrlExpiresAt: uploadLeaseExpiry(), + uploadLeaseNonce: resumedLease, + }, + resumePath, + "start-resumed-repath", + ); + if (resumedRepath === "unavailable") { + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + if (resumedRepath === "conflict") return leaseConflict(existing.id); + const resumed = await signUpload(resumePath, { deadline }); + // The row is on the new path with a live expiry, so a retry resumes + // through reuseLiveLease over that same path. + if (!resumed) return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + // Re-read before returning, for the same reason the re-arm does. + if (!await issuedLeaseIsCurrent( + existing.id, + { storagePath: resumePath, uploadLease: resumedLease }, + reloadLeaseRow, + )) return leaseConflict(existing.id); + return startOk({ + ok: true, kind: UPLOAD, resumed: true, id: existing.id, maxBytes: MAX_STORED_BYTES, + ...resumed, uploadLease: resumedLease, + }); + } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return NextResponse.json({ ok: false, reason: "unknown-project-or-cost-code" }, { status: 400 }); + } + throw error; + } + + const signed = await signUpload(storagePath, { deadline }); + if (!signed) { + // THE ROW ONLY GOES IF NOBODY ELSE ADOPTED IT. + // + // Creating the row and signing its URL are two steps, and a concurrent + // /start for the same sourceRef can complete BOTH of its own steps in + // the gap: it hits the unique violation, finds this row with a live + // lease, and reuseLiveLease hands it a working URL over this very path. + // The unconditional delete this replaces then removed the row that + // retry had just adopted — its bytes landed at a path no row pointed + // at, /finalize 404d on an id that no longer existed, and the + // sourceRef stopped protecting anything. See discardUnresumedLease for + // the columns the CAS reads and why the expiry alone could NOT see the + // reuse: an adoption writes "now + 2h" exactly as this request did, so + // the two can be the same millisecond. `leaseNonce` is what actually + // identifies this request's lease. + // + // Still best effort, exactly as before: a cleanup that could not run at + // all leaves a STAGING row with no object, which the stale-STAGING + // sweep already knows how to park. It never leaves a row deleted. + const discarded = await discardUnresumedLease( + { + id, + storagePath, + uploadLeaseVersion: 1, + uploadUrlExpiresAt: leaseExpiresAt, + uploadLeaseNonce: leaseNonce, + }, + prisma.receiptIntake, + ).catch(() => null); + if (discarded === "resumed") { + // Somebody else owns this row and their URL works. Reporting our + // own signer failure would tell a client to retry a row that is + // alive and in good hands; the idempotent conflict says who to ask. + return leaseConflict(id); + } + return NextResponse.json({ ok: false, reason: "storage-unavailable" }, { status: 503 }); + } + + // AND THE CREATOR RE-READS TOO. It writes the row, then signs, then + // answers -- and a concurrent /start for the same sourceRef can adopt or + // repath this row inside that gap. It was returning the nonce it had + // generated, never re-checked. + if (!await issuedLeaseIsCurrent( + created.id, + { storagePath, uploadLease: leaseNonce }, + reloadLeaseRow, + )) return leaseConflict(created.id); + return startOk({ + ok: true, + kind: UPLOAD, + id: created.id, + sourceRef: created.sourceRef, + state: created.state, + maxBytes: MAX_STORED_BYTES, + ...signed, + uploadLease: leaseNonce, + }); +} + +/** + * REPOINT A ROW AND REMEMBER THE OBJECT IT LEAVES BEHIND, ATOMICALLY. + * + * Both destructive /start branches do the same two writes: a fenced CAS that + * moves `storagePath` to a freshly-versioned path, and a cleanup entry for the + * path it just abandoned. They were separate statements, and the second one's + * failure was swallowed — so a transient database error left bytes in a + * private bucket that no row referenced, no event remembered and no sweep + * would ever look at. Permanently, and silently. + * + * In one transaction they are all-or-nothing: either the row moves AND the + * orphan is queued, or the row does not move and the object is still reachable + * through it. `unavailable` is the caller's 503 — the honest answer when we + * could not do both. + * + * The cleanup is SCHEDULED, not immediate. Normally the previous lease is + * already dead here (reuseLiveLease declined it), and `cleanupNotBefore` + * returns null so the sweep deletes at once. The exception is a caller that + * changed its declared extension: liveLeasePath then refuses to reuse the + * path even though the URL still works, and deleting under that live + * capability would let its holder PUT the object straight back. + */ +async function repathWithCleanup( + /** + * The row AS OBSERVED. The fence is built from it here rather than handed + * in: a `fence: Record` parameter let a caller pass half + * the lease identity, and one did — the resume branch pinned state and + * version while `reuseLiveLease` moves only the nonce and the expiry. It + * also put the fence somewhere no source check could see it, because the + * Prisma call in this function names an opaque parameter rather than a + * builder. Taking the row closes both. + */ + existing: ObservedRow & { id: string; storagePath: string; uploadUrlExpiresAt: Date | null; createdAt: Date }, + data: Record, + nextPath: string, + reason: string, +): Promise<"moved" | "conflict" | "unavailable"> { + try { + return await prisma.$transaction(async tx => { + const { count } = await tx.receiptIntake.updateMany({ + where: { id: existing.id, ...leaseFence(existing) }, + data, + }); + if (count === 0) return "conflict" as const; + // Only when the path actually MOVED. Queueing the path the row was + // just re-pointed AT would mark the live upload target for deletion. + if (nextPath !== existing.storagePath) { + await queueObjectCleanup( + tx, + existing.storagePath, + reason, + cleanupNotBefore(existing), + ); + } + return "moved" as const; + }); + } catch (error) { + // The row did NOT move: the transaction rolled back, so it still points + // at its old object and nothing is orphaned. A retryable answer. + console.error( + "[receipts/intake] repath transaction failed", + reason, + error instanceof Error ? error.name : "error", + ); + return "unavailable"; + } +} + +/** + * A LIVE LEASE THIS REQUEST DISAGREES WITH. + * + * Distinct from `leaseConflict` on purpose: that one means "the row moved + * while your URL was being issued, try again" and a retry usually works. + * This one means "somebody else holds a live lease for a DIFFERENT document + * or a different file type", and retrying changes nothing until that lease + * lapses -- so the expiry is in the body, and the caller can wait for it or + * start a separate intake under its own sourceRef. + */ +function leaseIdentityConflict(existingId: string, field: string, expiresAt: Date) { + return NextResponse.json( + { + ok: false, + error: "lease-conflict", + reason: field === "sha256" + ? "a live upload lease for this row was issued for different bytes; wait for it to expire or start a separate intake" + : "a live upload lease for this row was issued for a different file type; wait for it to expire or start a separate intake", + field, + retryable: true, + leaseExpiresAt: expiresAt.toISOString(), + existingId, + }, + { status: 409 }, + ); +} + +function leaseConflict(existingId: string) { + return NextResponse.json( + { + ok: false, + error: "publish-conflict", + reason: "this row changed while a new upload URL was being issued; retry", + retryable: true, + existingId, + }, + { status: 409 }, + ); +} + +/** + * THE ROW, RE-READ, in exactly the shape the lease rule fences on. + * + * Every column leaseFence pins is selected here. A partial select would hand + * the rule an `undefined` where the row has a value, and a CAS built from that + * matches nothing — a silent, permanent conflict rather than a lost race. + */ +async function reloadLeaseRow(id: string) { + return prisma.receiptIntake.findUnique({ + where: { id }, + select: { + id: true, state: true, stateReason: true, storagePath: true, + uploadLeaseVersion: true, uploadLeaseNonce: true, uploadUrlExpiresAt: true, + // Part of a LIVE lease's identity, so the re-read has to carry it: + // an undefined here would let a retry ADOPT a hash the row already + // announced, which is the disagreement the guard exists to catch. + expectedSha256: true, + }, + }); +} + +/** The live wiring for the shared lease rule (src/lib/receipt-intake/upload-lease.ts). */ +const leaseDepsFor = (leaseDeadline: RouteDeadline | undefined) => ({ + db: prisma.receiptIntake, + // The adoption CAS is exclusive, and every issued lease is re-read after + // signing, so the rule needs its own way back to the row. + reload: reloadLeaseRow, + sign: (storagePath: string, opts: { upsert: boolean }) => + // The reuse rule runs inside a request, so it gets that request's + // remaining budget rather than a fresh allowance of its own. + signUpload(storagePath, { ...opts, deadline: leaseDeadline }), + expiresAt: uploadLeaseExpiry, +}); + +/** + * CREATE-ONLY BY DEFAULT. + * + * Every call in this file except the shared lease-reuse rule signs a path that + * a version bump has just made new, so nothing can be there and an overwrite + * capability would be handed out for no reason — and it would outlive the row + * it was issued for, which is what makes it worth withholding. Only + * `reuseLiveLease` asks for `upsert`, because replacing a partial upload at + * the SAME path is the whole point of the reuse (see bucket.ts). The sha + * checks above are what stop even that token from overwriting a DIFFERENT + * document. + */ +const signUpload = (storagePath: string, opts: { upsert?: boolean; deadline: RouteDeadline | undefined }) => + createReceiptUploadUrl(storagePath, opts); diff --git a/src/app/manager/receipts/page.tsx b/src/app/manager/receipts/page.tsx index 2ad7188e5..dfd8a2e4b 100644 --- a/src/app/manager/receipts/page.tsx +++ b/src/app/manager/receipts/page.tsx @@ -1,13 +1,26 @@ -export const dynamic = "force-dynamic"; -import { prisma } from "@/lib/prisma"; -import { getSessionOrDev } from "@/lib/auth"; -import { redirect } from "next/navigation"; -import ReceiptQueueClient from "./ReceiptQueueClient"; - -export default async function BookkeeperReceiptsPage() { - const session = await getSessionOrDev(); - if (!session?.user) redirect("/login"); - +export const dynamic = "force-dynamic"; +import { prisma } from "@/lib/prisma"; +import { getSessionOrDev } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { resolveReceiptUrls } from "@/lib/receipt-intake/receipt-url"; +import { STAFF_READ_ROLES } from "@/lib/receipt-intake/intake-auth"; +import ReceiptQueueClient from "./ReceiptQueueClient"; + +export default async function BookkeeperReceiptsPage() { + const session = await getSessionOrDev(); + if (!session?.user) redirect("/login"); + + // Same role gate as the GET /api/receipts/intake staff-queue read + // (STAFF_READ_ROLES): this page queries Expense directly and mints + // short-lived signed URLs for every receipt, so a session check alone let + // ANY logged-in role — not just ADMIN/MANAGER/FINANCE — browse the + // bookkeeper queue and its receipt images. Deny-by-default: no matching + // User (outside local dev) is not staff. + const user = await prisma.user.findUnique({ where: { email: session.user.email! } }); + if (!user ? process.env.NODE_ENV !== "development" : !STAFF_READ_ROLES.includes(user.role)) { + return
Access Denied. Bookkeeping staff only.
; + } + const [ pendingExpenses, importedExpenses, @@ -52,36 +65,45 @@ export default async function BookkeeperReceiptsPage() { orderBy: { code: "asc" }, }), ]); - - return ( -
-
+ + // `receiptUrl` is a stable `receipt-intake://` REFERENCE for anything the + // v2 pipeline booked (book.ts), not a link — the client renders it + // straight into an `href`, so it must be a short-lived signed URL by the + // time it gets there. A legacy absolute URL passes through unchanged. + const [resolvedPendingExpenses, resolvedImportedExpenses] = await Promise.all([ + resolveReceiptUrls(pendingExpenses), + resolveReceiptUrls(importedExpenses), + ]); + + return ( +
+

Bookkeeper Review Queue

Review receipt intake before accounting, and audit finalized expenses imported from QuickBooks. -

-
- -
-
- - - -
- Receipt forwarding address: Forward emailed receipts to{" "} - receipts@probuild.goldentouchremodeling.com{" "} - — they land in the Drive receipts archive, where the receipt automation processes them. -
-
-
- +

+
+ +
+
+ + + +
+ Receipt forwarding address: Forward emailed receipts to{" "} + receipts@probuild.goldentouchremodeling.com{" "} + — they land in the Drive receipts archive, where the receipt automation processes them. +
+
+
+ -
- ); -} + costCodes={costCodes} + /> +
+ ); +} diff --git a/src/lib/automation-events.ts b/src/lib/automation-events.ts index aa0dd7ec0..a3d30fa23 100644 --- a/src/lib/automation-events.ts +++ b/src/lib/automation-events.ts @@ -18,7 +18,19 @@ import type { Prisma } from "@prisma/client"; */ export interface AutomationEventInput { - kind: "receipt-push" | "qbo-sync" | "receipt-stage" | "setting" | "qbo-payments-sync"; + kind: + | "receipt-push" + | "qbo-sync" + | "receipt-stage" + | "setting" + | "qbo-payments-sync" + /** + * A rejected intake object whose DELETE from storage failed. The row is + * already gone, so nothing else remembers the orphan — without this the + * bytes sit in a private bucket forever, unreferenced. The receipt + * worker's sweep retries the deletion and resolves the event. + */ + | "storage-cleanup-pending"; stage?: string; status: string; reason?: string; @@ -386,6 +398,32 @@ export function resolveEventFileId(e: { driveFileId: string | null; detail: stri return null; } +/** + * The v2 pipeline's own row id, for events that have no Drive file behind them. + * + * `fileId` means a DRIVE file id — it is what `driveFileId` is dual-written + * from, what the cutover queries to decide "did v1 book this", and what a + * DocNumber is derived from. The intake worker used to put its cuid there for + * email, chat, mobile and web receipts, which filled that column with ids no + * Drive query can ever match. Those rows carry `intakeId` instead, and it is a + * first-class identity here: an intake beacon and its push event group by it + * exactly as a Drive pair groups by fileId. + */ +export function resolveEventIntakeId(e: { detail: string | null }): string | null { + if (!e.detail) return null; + try { + const d = JSON.parse(e.detail) as { intakeId?: unknown }; + return typeof d.intakeId === "string" && d.intakeId ? d.intakeId : null; + } catch { + return null; + } +} + +/** Any identity at all — the test every "id-less event" branch actually means. */ +function hasResolvedId(e: { driveFileId: string | null; qbPurchaseId: string | null; detail: string | null }): boolean { + return !!(resolveEventFileId(e) || resolveEventQbPurchaseId(e) || resolveEventIntakeId(e)); +} + /** Same idea as `resolveEventFileId`, for the QBO Purchase id. */ export function resolveEventQbPurchaseId(e: { qbPurchaseId: string | null; detail: string | null }): string | null { if (e.qbPurchaseId) return e.qbPurchaseId; @@ -535,9 +573,15 @@ export function groupEventsIntoJourneys(events: JourneyEventInput[]): Map(); const byQbPurchaseId = new Map(); + // v2 rows with no Drive file behind them (email, chat, mobile, web) are + // keyed by their intake id. It is exactly as strong an identity as a Drive + // file id — a cuid, unique per row — and without it those receipts would + // fall through to the docNumber-prefix heuristic below. + const byIntakeId = new Map(); sorted.forEach((e, i) => { const fileId = resolveEventFileId(e); const qbPurchaseId = resolveEventQbPurchaseId(e); + const intakeId = resolveEventIntakeId(e); if (fileId) { const existing = byFileId.get(fileId); if (existing !== undefined) union(i, existing); @@ -548,11 +592,20 @@ export function groupEventsIntoJourneys(events: JourneyEventInput[]): Map>(); sorted.forEach((e, i) => { - if (!resolveEventFileId(e) && !resolveEventQbPurchaseId(e)) return; + if (!hasResolvedId(e)) return; const doc = e.docNumber as string; const roots = idRootsByDoc.get(doc) ?? new Set(); roots.add(find(i)); @@ -585,7 +638,7 @@ export function groupEventsIntoJourneys(events: JourneyEventInput[]): Map(); sorted.forEach((e, i) => { - if (resolveEventFileId(e) || resolveEventQbPurchaseId(e)) return; + if (hasResolvedId(e)) return; const roots = idRootsByDoc.get(e.docNumber as string); if (roots && roots.size === 1) { union(i, [...roots][0]); @@ -600,7 +653,7 @@ export function groupEventsIntoJourneys(events: JourneyEventInput[]): Map(); sorted.forEach((e, i) => { - if (resolveEventFileId(e) || resolveEventQbPurchaseId(e)) return; + if (hasResolvedId(e)) return; const doc = e.docNumber as string; const roots = idRootsByDoc.get(doc); if (roots && roots.size === 1) return; // already bridged above @@ -622,13 +675,22 @@ export function groupEventsIntoJourneys(events: JourneyEventInput[]): Map sorted[i]); // already ascending (createdAt, id) let driveFileId: string | null = null; let qbPurchaseId: string | null = null; + let intakeId: string | null = null; for (const e of groupEvents) { driveFileId = driveFileId ?? resolveEventFileId(e); + intakeId = intakeId ?? resolveEventIntakeId(e); const qb = resolveEventQbPurchaseId(e); if (qb) qbPurchaseId = qb; } const doc = groupEvents[0].docNumber as string; - const key = driveFileId ?? (qbPurchaseId ? `qb:${qbPurchaseId}` : `prefix:${doc}`); + // `prefix:` is the LAST resort, and it is not an identity: two + // different receipts can share a 21-char DocNumber prefix, so keying on + // it merges them into one journey. A v2 row with no Drive file has a + // real id of its own — the intake cuid — and it belongs here, or every + // email/chat/mobile receipt that collides on a prefix is presented as + // one receipt in the Command Center. + const key = driveFileId + ?? (qbPurchaseId ? `qb:${qbPurchaseId}` : intakeId ? `intake:${intakeId}` : `prefix:${doc}`); // Finding 5: real id evidence (driveFileId/qbPurchaseId) on the // group is necessary but not sufficient — if ANY member only joined @@ -647,7 +709,10 @@ export function groupEventsIntoJourneys(events: JourneyEventInput[]): Map; +} + +/** The persisted shape: `|`. Two fields, one string column. */ +function encode(expiresAt: Date, token: string): string { + return `${expiresAt.toISOString()}|${token}`; +} + +function expiryOf(value: string): number { + const iso = value.split("|", 1)[0]; + const at = Date.parse(iso); + // An unparseable value is a corrupt lease, and a corrupt lease that read as + // "live" would wedge the cron forever. Treat it as expired: the CAS below + // still makes the takeover safe. + return Number.isFinite(at) ? at : 0; +} + +export interface CronLeaseStore { + get(key: string): Promise; + /** Create only if absent. False when the row already exists. */ + insert(key: string, value: string): Promise; + /** Swap `from` for `to`. False when the stored value is no longer `from`. */ + swap(key: string, from: string, to: string): Promise; + /** Remove, only while the value is still `expected`. */ + remove(key: string, expected: string): Promise; +} + +/** AutomationSetting-backed. The `key` column is the primary key, so insert races resolve there. */ +export const automationSettingLeaseStore: CronLeaseStore = { + async get(key) { + const row = await prisma.automationSetting.findUnique({ where: { key }, select: { value: true } }); + return row?.value ?? null; + }, + async insert(key, value) { + try { + await prisma.automationSetting.create({ data: { key, value } }); + return true; + } catch { + // A unique violation is the LOSING side of a first-run race, which + // is a normal outcome, not an error. Any other failure also means + // we did not get the lease, which is the same answer. + return false; + } + }, + async swap(key, from, to) { + // The CAS. `value: from` in the WHERE is what makes this safe without a + // transaction: two invocations reading the same expired lease both try + // to swap the SAME old string, and exactly one update matches a row. + const { count } = await prisma.automationSetting.updateMany({ + where: { key, value: from }, + data: { value: to }, + }); + return count > 0; + }, + async remove(key, expected) { + await prisma.automationSetting.deleteMany({ where: { key, value: expected } }); + }, +}; + +/** + * Take the lease, or return null if someone else holds a live one. + * + * `now` and `store` are injected so this is a unit test rather than a fact + * about production that only a race in prod could ever exercise. + */ +export async function acquireCronLease( + key: string, + ttlMs: number, + opts: { store?: CronLeaseStore; now?: () => Date; token?: string } = {}, +): Promise { + const store = opts.store ?? automationSettingLeaseStore; + const now = opts.now ?? (() => new Date()); + const token = opts.token ?? randomUUID(); + + let existing: string | null; + try { + existing = await store.get(key); + } catch { + // Fail closed: without knowing, we must not run. + return null; + } + + const at = now(); + const mine = encode(new Date(at.getTime() + ttlMs), token); + + let won = false; + try { + if (existing === null) { + won = await store.insert(key, mine); + } else if (expiryOf(existing) > at.getTime()) { + // Somebody is holding it and has not run out of time. + return null; + } else { + // Expired — a crashed invocation, or one the platform killed. Take + // it over, but only if it is STILL the expired value we read: two + // invocations racing here must not both believe they won. + won = await store.swap(key, existing, mine); + } + } catch { + return null; + } + if (!won) return null; + + return { + token, + async release() { + try { + // Fenced on OUR value. An invocation that overran, lost its + // lease, and is only now finishing releases nothing — the row + // it would delete belongs to whoever took over. + // Deleting an already-deleted row matches nothing, so a + // second call is a no-op rather than a mistake. + await store.remove(key, mine); + } catch { + // A lease left behind expires on its own; the next run takes it + // over. Never let a cleanup failure fail the run it protected. + } + }, + }; +} diff --git a/src/lib/pipeline-health.ts b/src/lib/pipeline-health.ts index fd942a64f..afdd0a00b 100644 --- a/src/lib/pipeline-health.ts +++ b/src/lib/pipeline-health.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; +import { STAGING_SWEEP_MINUTES } from "./receipt-intake/worker"; import { PAID_DELETION_UNRESOLVABLE, PAYLINK_MISSING_MARKER, @@ -112,6 +113,51 @@ export interface PipelineHealth { bank: TimestampProbe; /** Automation events (ANY kind) that errored in the last 24h. */ stuck: CountProbe; + /** + * Receipt Pipeline v2 (ReceiptIntake). Every other probe here reads + * AutomationEvent, which only ever records a BOOKING — so a v2 row that + * never reaches QuickBooks is invisible to all of them. A jammed intake + * queue reported a perfectly healthy pipeline right up until somebody + * noticed the expenses were missing. + */ + intake: { + /** + * Three shapes of "the worker stopped": RECEIVED/BOOKING overdue, + * STAGING overdue (the route died mid-upload, or the sweeper is dead), + * and live READ overdue (a worker that died right after routing). + */ + stuck: CountProbe; + /** NEEDS_REVIEW backlog. Reported always; a reason only when rows are STUCK. */ + needsReview: CountProbe; + /** + * NEEDS_JOB rows older than INTAKE_STUCK_HOURS — a receipt nobody has + * matched to a job. Terminal for the worker, so it can pile up + * indefinitely while every other probe reads green: the exact + * silent-failure mode this whole check exists to eliminate. Only the + * OVERDUE ones count, so a receipt uploaded ten minutes ago is not an + * alert. + */ + unassigned: CountProbe; + /** + * TERMINAL, UNBOOKED, AND WAITING ON A PERSON — the states nothing + * else here can see. + * + * Enumerated from RECEIPT_INTAKE_STATES rather than guessed at. + * STAGING/RECEIVED/READ/BOOKING are working states (`stuck` covers + * them); BOOKED and ARCHIVED reached QuickBooks; SHADOW_DONE was + * booked by v1; DUPLICATE and NON_RECEIPT are decided answers that + * deliberately never book; NEEDS_REVIEW and NEEDS_JOB have their own + * probes above; VOID has no writer anywhere in the codebase. That + * leaves SHADOW_QUARANTINE, which the cutover creates for a + * pre-boundary row with no evidence v1 booked it and no Drive identity + * to make a v2 booking idempotent. It is terminal, it is NEVER + * auto-requeued, and it is an expense that has reached nobody's books + * — so a queue of them could grow indefinitely while every other probe + * read green, which is the exact silent failure this check exists to + * eliminate. + */ + quarantined: CountProbe; + }; /** * Events in the last 24h where QuickBooks refused the CREDENTIAL (401/403, * or a refresh that stranded). Optional so an older snapshot still fits. @@ -146,6 +192,24 @@ export interface PipelineHealth { maintenanceRun?: { status: ProbeStatus; reason?: ProbeFailure; at: string | null }; } +/** A row this old in a working state has not been picked up, it has jammed. */ +export const INTAKE_STUCK_HOURS = 6; +/** + * STAGING is meant to last one HTTP request. Half an hour of it means the + * intake route died mid-upload or the sweeper is not running — and since + * STAGING is invisible to the worker's claim by design, nothing else would + * ever notice. + * + * ROW AGE ALONE IS THE WRONG QUESTION, same as it is for the sweeper's own + * "is this row stuck" call (worker.ts's uploadLeaseActive). A signed upload + * URL is valid for two hours (SIGNED_UPLOAD_TTL_MS), and a resumed /start + * re-issues one on an EXISTING row without touching createdAt — so a client + * on a slow connection, still inside its own upload window, used to get + * flagged "stuck" here while its upload was about to land. The count below + * only counts a STAGING row past this age AND past its own upload lease. + */ +export const INTAKE_STAGING_STUCK_MINUTES = 30; + /** * Intuit's own status page. * @@ -320,6 +384,14 @@ export function evaluatePipelineHealth(input: { receipts24h: CountsProbe; bank: TimestampProbe; stuck: CountProbe; + intakeStuck: CountProbe; + intakeNeedsReview: CountProbe; + intakeUnassigned: CountProbe; + /** + * Optional so an older caller (or a stored snapshot) still evaluates. An + * ABSENT probe is silent; a probe that ran and found rows is a reason. + */ + intakeQuarantined?: CountProbe; /** Optional so existing snapshots stay valid; absent means "not measured". */ qboAuth?: CountProbe; /** @@ -355,6 +427,10 @@ export function evaluatePipelineHealth(input: { ["receipts24h", input.receipts24h], ["bank", input.bank], ["stuck", input.stuck], + ["intakeStuck", input.intakeStuck], + ["intakeNeedsReview", input.intakeNeedsReview], + ["intakeUnassigned", input.intakeUnassigned], + ...(input.intakeQuarantined ? [["intakeQuarantined", input.intakeQuarantined] as [string, { status: ProbeStatus }]] : []), ["payLinksPending", input.payLinksPending], ]; for (const [name, probe] of namedProbes) { @@ -421,6 +497,38 @@ export function evaluatePipelineHealth(input: { reasons.push(`errors-24h:${input.stuck.count}`); } + // A row sitting in RECEIVED/BOOKING/STAGING or live READ past its fuse means + // the worker is not draining the queue — a wedged cron, an exhausted retry + // budget, a storage outage. The backlog number rides along so the digest can + // say how big the hole is, but only the STUCK count is a failure: + // NEEDS_REVIEW rows are working as designed (a human was asked a question) + // and would otherwise hold the pipeline red until somebody cleared them. + if (input.intakeStuck.status === "ok" && input.intakeStuck.count > 0) { + const backlog = + input.intakeNeedsReview.status === "ok" ? `,needs-review:${input.intakeNeedsReview.count}` : ""; + reasons.push(`intake-stuck:${input.intakeStuck.count}${backlog}`); + } + + // A receipt waiting hours for someone to say which job it belongs to is not + // "working as designed" — it is an expense that will never reach job cost. + // Its own reason, because the fix is different: assign a project, not + // restart a worker. + if (input.intakeUnassigned.status === "ok" && input.intakeUnassigned.count > 0) { + reasons.push(`intake-unassigned:${input.intakeUnassigned.count}`); + } + + // A quarantined shadow-week row is a receipt the cutover could neither + // retire nor hand to v2, so NOBODY has booked it and nothing will until a + // person checks QuickBooks and uses "book anyway". Terminal and never + // auto-requeued, which is precisely why it needs its own reason: unlike + // the NEEDS_REVIEW backlog it is not "working as designed", and unlike a + // stuck row no restart clears it. It is also not covered by ANY other + // probe here — the count is why the spec's claim that these rows are + // visible via pipeline health is true rather than aspirational. + if (input.intakeQuarantined?.status === "ok" && input.intakeQuarantined.count > 0) { + reasons.push(`receipt-quarantine:${input.intakeQuarantined.count}`); + } + if (input.lastPaymentsSync.status === "ok") { // The money rail's heartbeat. Null means the hourly cron has never // completed a run we can see; stale means it stopped. Either way the @@ -705,7 +813,9 @@ export async function getPipelineHealth(): Promise { const [ intuit, lastPurchase, purchaseSyncRun, lastPush, lastPaymentsSync, receiptRows, - lastBankLine, stuck, qboAuth, payLinksPending, + lastBankLine, stuck, + intakeStuck, intakeNeedsReview, intakeUnassigned, intakeQuarantined, + qboAuth, payLinksPending, payLinksMissing, parkedCreates, parkedDocumentSyncs, pendingDeletions, unreconciledMoney, maintenanceRun, ] = await Promise.all([ fetchIntuitStatus(), @@ -840,6 +950,82 @@ export async function getPipelineHealth(): Promise { }), 0, ), + // ReceiptIntake: the v2 queue. Three shapes of "the worker stopped", + // all of which used to read green: + // RECEIVED/BOOKING overdue — the classic jam. + // STAGING overdue — the route died mid-upload, or the sweeper + // is dead. STAGING is invisible to the + // claim by design, so nothing else notices. + // READ overdue, LIVE only — a worker that died right after routing + // leaves a bookable row parked forever. + // dryRun rows legitimately REST in READ for + // the whole shadow week, so they are + // excluded or the check is red by design. + probe( + "intakeStuck", + () => + prisma.receiptIntake.count({ + where: { + OR: [ + { + state: { in: ["RECEIVED", "BOOKING"] }, + createdAt: { lt: new Date(now - INTAKE_STUCK_HOURS * HOUR_MS) }, + }, + { + state: "STAGING", + createdAt: { lt: new Date(now - INTAKE_STAGING_STUCK_MINUTES * 60_000) }, + // NOT uploadLeaseActive, expressed as a query + // rather than called as a predicate (this is + // an aggregate count, not a row scan): a live + // lease means either an explicit expiry that + // has not passed yet, or — for the inline, + // no-signed-URL path — a row young enough to + // still be inside the sweeper's own grace + // window. A STAGING row satisfying either is + // still on the clock, not stuck. + OR: [ + { uploadUrlExpiresAt: { lte: new Date(now) } }, + { + uploadUrlExpiresAt: null, + createdAt: { lt: new Date(now - STAGING_SWEEP_MINUTES * 60_000) }, + }, + ], + }, + { + state: "READ", + dryRun: false, + createdAt: { lt: new Date(now - INTAKE_STUCK_HOURS * HOUR_MS) }, + }, + ], + }, + }), + 0, + ), + probe( + "intakeNeedsReview", + () => prisma.receiptIntake.count({ where: { state: "NEEDS_REVIEW" } }), + 0, + ), + probe( + "intakeUnassigned", + () => + prisma.receiptIntake.count({ + where: { + state: "NEEDS_JOB", + createdAt: { lt: new Date(now - INTAKE_STUCK_HOURS * HOUR_MS) }, + }, + }), + 0, + ), + // NO AGE THRESHOLD, unlike NEEDS_JOB. A quarantined row is terminal the + // instant the cutover writes it — nothing is coming to move it on — so + // "wait six hours in case it resolves itself" would be waiting for + // something that cannot happen. + probe( + "intakeQuarantined", + () => prisma.receiptIntake.count({ where: { state: "SHADOW_QUARANTINE" } }), + 0, + ), // Separate from `stuck` on purpose: this one names the fix. probe( "qboAuth", @@ -982,6 +1168,22 @@ export async function getPipelineHealth(): Promise { at: lastBankLine.value?.toISOString() ?? null, }, stuck: { status: stuck.status, reason: stuck.reason, count: stuck.value }, + intakeStuck: { status: intakeStuck.status, reason: intakeStuck.reason, count: intakeStuck.value }, + intakeNeedsReview: { + status: intakeNeedsReview.status, + reason: intakeNeedsReview.reason, + count: intakeNeedsReview.value, + }, + intakeUnassigned: { + status: intakeUnassigned.status, + reason: intakeUnassigned.reason, + count: intakeUnassigned.value, + }, + intakeQuarantined: { + status: intakeQuarantined.status, + reason: intakeQuarantined.reason, + count: intakeQuarantined.value, + }, qboAuth: { status: qboAuth.status, reason: qboAuth.reason, count: qboAuth.value }, payLinksPending: { status: payLinksPending.status, reason: payLinksPending.reason, count: payLinksPending.value }, payLinksMissing: { status: payLinksMissing.status, reason: payLinksMissing.reason, count: payLinksMissing.value }, @@ -1007,6 +1209,12 @@ export async function getPipelineHealth(): Promise { receipts24h: snapshot.receipts24h, bank: snapshot.bank, stuck: snapshot.stuck, + intake: { + stuck: snapshot.intakeStuck, + needsReview: snapshot.intakeNeedsReview, + unassigned: snapshot.intakeUnassigned, + quarantined: snapshot.intakeQuarantined, + }, qboAuth: snapshot.qboAuth, payLinksPending: snapshot.payLinksPending, payLinksMissing: snapshot.payLinksMissing, @@ -1075,6 +1283,24 @@ export function formatPipelineDigest(health: PipelineHealth): { subject: string; : "no lines" }`, `Automation errors (24h, all kinds): ${health.stuck.status === "error" ? "unavailable (probe failed)" : health.stuck.count}`, + // Optional-chained on purpose: a digest that THROWS means no morning + // email at all, which is strictly worse than a digest missing a line. + // Same rule as "no probe may throw" above. + `Receipt intake stuck >${INTAKE_STUCK_HOURS}h: ${ + health.intake?.stuck?.status === "error" ? "unavailable (probe failed)" : health.intake?.stuck?.count ?? "unavailable" + }`, + `Receipt intake awaiting review: ${ + health.intake?.needsReview?.status === "error" ? "unavailable (probe failed)" : health.intake?.needsReview?.count ?? "unavailable" + }`, + `Receipt intake awaiting a job (>${INTAKE_STUCK_HOURS}h): ${ + health.intake?.unassigned?.status === "error" ? "unavailable (probe failed)" : health.intake?.unassigned?.count ?? "unavailable" + }`, + // Its own line, not folded into "awaiting review": a quarantined row + // needs somebody to check QuickBooks and decide, which is a different + // action from clearing a review item. + `Receipt intake quarantined (cutover, needs a decision): ${ + health.intake?.quarantined?.status === "error" ? "unavailable (probe failed)" : health.intake?.quarantined?.count ?? "unavailable" + }`, ]; if (health.payLinksMissing?.status === "ok" && health.payLinksMissing.count > 0) { lines.push(`${health.payLinksMissing.count} QuickBooks invoice(s) have NO payable link after repeated retries — open them in QuickBooks and enable payments by hand.`); diff --git a/src/lib/qbo-expense-sync.ts b/src/lib/qbo-expense-sync.ts index a49f09e66..532e027a3 100644 --- a/src/lib/qbo-expense-sync.ts +++ b/src/lib/qbo-expense-sync.ts @@ -574,8 +574,23 @@ function expenseMatchesQboWrite( ); } -async function lockQboExpense( - transaction: ExpenseTransaction, +/** The one capability `lockQboExpense` needs, so any writer can share it. */ +export interface QboExpenseLockClient { + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; +} + +/** + * THE per-Purchase advisory lock. Exported so every writer of an Expense keyed + * by a QBO Purchase id takes the SAME one. + * + * The receipt-intake worker links an Expense by `qbPurchaseId` too, and it was + * doing so unlocked — so the importer could create the row between that + * worker's lookup and its link, and the two disagreed about what the Expense + * said. Copying the lock string into book.ts would have been two constants + * that must never drift; sharing the function is one. + */ +export async function lockQboExpense( + transaction: QboExpenseLockClient, qbPurchaseId: string, ): Promise { // Serialize all writers for one QBO Purchase id before reading its SyncToken. diff --git a/src/lib/qbo-receipt-push.ts b/src/lib/qbo-receipt-push.ts index fd29a5d16..a70305d32 100644 --- a/src/lib/qbo-receipt-push.ts +++ b/src/lib/qbo-receipt-push.ts @@ -233,8 +233,92 @@ export interface CreateQBReceiptPurchaseInput { */ export type ReceiptAttachmentStatus = "attached" | "already-attached" | "skipped" | `failed:${string}`; +/** + * What QuickBooks ACTUALLY holds for a Purchase that already exists, read off + * the entity rather than assumed from the payload we would have sent. + * + * `projectNames` is a list because the job rides on each LINE's CustomerRef and + * a Purchase can carry several. It is a SUMMARY, kept for the review snapshot — + * the verdict is decided from `lines`, because a set of names cannot represent + * the line that carried no name at all. + */ +export interface BookedPurchaseValues { + /** QBO's `TotalAmt`. Null only when the entity did not carry a usable one. */ + totalAmount: number | null; + /** QBO's `TxnDate`, as a calendar day. Null when absent or not a real date. */ + txnDate: string | null; + /** `EntityRef.name`. Null when QBO returned the ref without a display name. */ + vendor: string | null; + /** Distinct `CustomerRef.name`s across the expense lines. */ + projectNames: string[]; + /** + * EVERY entry of `Line`, in order, with what could be read of its job + * attribution — including the ones that carry none. The distinct-name set + * above cannot answer "is all of this money on this job", because a line + * with no customer, or one this code cannot parse at all, leaves no trace + * in it: see attributionAgrees. + */ + lines: BookedExpenseLine[]; + /** Dollars posted to the reimbursable-sales-tax account. */ + taxAmount: number; +} + +/** + * One `Purchase.Line` entry's job attribution, read off the entity. + * + * `readable: false` means the entry is not an account-based expense line, so + * there is no `AccountBasedExpenseLineDetail` to find a `CustomerRef` on at + * all. That is NOT the same as an account-based line that simply carries no + * customer — both fail the check, but only one of them is a shape this code + * understands, and conflating them would hide the difference from the review. + */ +export interface BookedExpenseLine { + /** Posted to the reimbursable-sales-tax account rather than an expense one. */ + tax: boolean; + /** `CustomerRef.value` — the identity. Null when the ref is absent or unusable. */ + customerId: string | null; + /** `CustomerRef.name` — a display string, and optional per QBO's own docs. */ + customerName: string | null; + /** False when the entry is not an account-based expense line. */ + readable: boolean; +} + +/** + * What may be done with a Purchase that is already in the books. + * + * - `match` — the books agree with this document; book it as planned. + * - `derive` — they disagree about the AMOUNT, DATE or VENDOR. QuickBooks is + * the booked truth for those (real money posted against them, and the + * difference is OCR noise on our side), so the Expense is written from the + * QBO values and the difference is recorded. + * - `review` — they disagree about the PROJECT or the TAX SPLIT, the Purchase + * is not FULLY attributed to that project (see attributionAgrees), or QBO did + * not give a usable total or date at all. Those are attribution decisions, not + * noise: which job carries the cost, and whether the sales tax is sitting on + * the reclaimable account. Neither side may be preferred automatically, so no + * Expense is written and a human looks. + */ +export interface ExistingPurchaseCheck { + verdict: "match" | "derive" | "review"; + /** Stable, sorted field names: "amount" | "date" | "vendor" | "project" | "tax". */ + differences: string[]; + booked: BookedPurchaseValues; +} + export type CreateQBReceiptPurchaseResult = - | { ok: true; qbPurchaseId: string; docNumber: string; alreadyExists: true; attachment: ReceiptAttachmentStatus } + | { + ok: true; + qbPurchaseId: string; + docNumber: string; + alreadyExists: true; + attachment: ReceiptAttachmentStatus; + /** + * The books, compared against what THIS document says. Present only on + * the alreadyExists branch, because it is the only one where a Purchase + * we did not write in this call decides what the Expense should say. + */ + existing: ExistingPurchaseCheck; + } | { ok: true; qbPurchaseId: string; docNumber: string; alreadyExists: false; attachment: ReceiptAttachmentStatus } | { ok: false; reason: "project-not-matched"; projectName: string } | { ok: false; reason: "docnumber-conflict"; docNumber: string } @@ -254,6 +338,32 @@ export interface QboReceiptProjectCandidate { export interface QboReceiptPushDependencies { qbQueryFn: (tokens: QBTokens, query: string) => Promise; qbCreateFn: (tokens: QBTokens, payload: Record, requestId: string) => Promise<{ id: string }>; + /** + * Invoked IMMEDIATELY before qbCreateFn, and nowhere else. + * + * Callers that record "a Purchase may now exist" need that record to happen + * at the last possible instant. Everything above this line — the DocNumber + * query, the project match, the vendor/customer ensures, the account + * verification, the money validation — can fail without any Purchase being + * created, and a caller that marked earlier would treat those as + * "might have booked" forever. + * + * Throwing from this hook aborts the create, which is the point: it is also + * the caller's last chance to check it still owns the row. + */ + onBeforeCreate?: () => Promise; + /** + * Invoked when the idempotency query finds THIS file's Purchase already in + * QuickBooks, immediately before anything else is done with it. + * + * The alreadyExists branch returns WITHOUT ever reaching qbCreateFn, so + * `onBeforeCreate` never fires on it. That left the caller unable to tell + * "a Purchase exists for this row" from "nothing was ever sent" — and a row + * that exhausted its retries on this path would hand back its dedup key + * while a real Purchase sat in the books, so a resubmission would book the + * same receipt twice. This hook is that signal. + */ + onExistingPurchase?: () => Promise; ensureVendorFn: (tokens: QBTokens, name: string) => Promise; // Injectable (unlike the plain re-export of ensureQBCustomer) because the // customer is now resolved on EVERY create — there is no more per-client @@ -307,6 +417,237 @@ function findExactProjectMatch( return matches.length === 1 ? matches[0] : null; } +/** + * The cents a booked value may differ by and still count as the same money. + * + * Two, matching the tolerance the group/total reconciliation above already + * allows: a two-line tax split can round each half independently. + */ +const BOOKED_AMOUNT_TOLERANCE_CENTS = 2; + +/** A QBO ReferenceType's display name, when it carried one. */ +function refName(ref: unknown): string | null { + if (!ref || typeof ref !== "object") return null; + const name = (ref as { name?: unknown }).name; + return typeof name === "string" && name.trim() ? name.trim() : null; +} + +/** + * A QBO ReferenceType's id. The `name` beside it is a display string two + * different customers can share; THIS is the identity. + */ +function refValue(ref: unknown): string | null { + if (!ref || typeof ref !== "object") return null; + const value = (ref as { value?: unknown }).value; + if (typeof value === "string") return value.trim() || null; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return null; +} + +function expenseLineDetail(line: unknown): Record | null { + if (!line || typeof line !== "object") return null; + const detail = (line as { AccountBasedExpenseLineDetail?: unknown }).AccountBasedExpenseLineDetail; + return detail && typeof detail === "object" ? (detail as Record) : null; +} + +/** + * READ THE BOOKS. Every value comes off the QBO entity — nothing is inferred + * from the payload this process would have sent, because the whole point is to + * find out where the two disagree. + */ +export function readBookedPurchase(purchase: Record, taxAccountId: string): BookedPurchaseValues { + const rawTotal = Number(purchase.TotalAmt); + const rawDate = purchase.TxnDate; + const lines = Array.isArray(purchase.Line) ? purchase.Line : []; + + let taxCents = 0; + const projectNames = new Set(); + const expenseLines: BookedExpenseLine[] = []; + for (const line of lines) { + const detail = expenseLineDetail(line); + // RECORDED, not skipped. A line this code cannot parse still carries + // money on this Purchase, and dropping it here is what let a partly + // attributed document read as a fully attributed one. + if (!detail) { + expenseLines.push({ tax: false, customerId: null, customerName: null, readable: false }); + continue; + } + const customer = refName(detail.CustomerRef); + if (customer) projectNames.add(customer); + const account = (detail.AccountRef as { value?: unknown } | undefined)?.value; + const tax = account !== undefined && String(account) === taxAccountId; + if (tax) { + const amount = Number((line as { Amount?: unknown }).Amount); + if (Number.isFinite(amount)) taxCents += Math.round(amount * 100); + } + expenseLines.push({ + tax, + customerId: refValue(detail.CustomerRef), + customerName: customer, + readable: true, + }); + } + + return { + totalAmount: Number.isFinite(rawTotal) && rawTotal > 0 ? rawTotal : null, + txnDate: isValidCalendarDate(rawDate) ? rawDate : null, + vendor: refName(purchase.EntityRef), + projectNames: Array.from(projectNames), + lines: expenseLines, + taxAmount: taxCents / 100, + }; +} + +/** + * IS EVERY DOLLAR ON THIS PURCHASE ON THE JOB THIS RECEIPT SAYS IT IS? + * + * The rule this replaces read the DISTINCT customer names off the lines and + * passed when exactly one of them matched. That answered "does some line agree" + * rather than "do they all", and the difference is money: a Purchase carrying a + * $50 line coded to this job plus a $100 line coded to nothing at all read as a + * clean match, and the Expense was then written for the whole $150 against a + * job QuickBooks only holds a third of. A Purchase with NO coded lines missed + * the check entirely — the guard was inside `projectNames.length > 0` — and + * passed unconditionally. + * + * So it is per line now, and the burden of proof runs the other way: the + * default is `review`, and a `match` has to be earned by every line. + * + * - Nothing readable to attribute is not a pass. "I could not check" must not + * read the same as "I checked and it agrees", exactly as for the total and + * the date above. + * - Every NON-TAX line must be coded. A tax line need not be: it posts to the + * reclaimable-sales-tax account, which is its own attribution, and an + * uncoded one misfiles nothing. + * - A customer NAME that disagrees is fatal wherever it sits, tax line + * included — that is the old two-jobs ambiguity, kept. + * - Identity between lines is compared on `CustomerRef.value`, never the + * display name: two QBO customers can share one. More than one id on a + * document is the same split-across-jobs ambiguity by a route the name + * comparison cannot see. + * - And at least one line has to be confirmed BY NAME. Ids agreeing with each + * other says the lines agree with each other, not that they agree with this + * receipt: the expected customer's id is not known on this branch (it is + * resolved after the idempotency query, and resolving it here would CREATE + * a QBO customer on a replay path), so the name is the only form of the + * expected identity available to compare against. + */ +function attributionAgrees(lines: BookedExpenseLine[], expectedProjectName: string): boolean { + if (lines.length === 0) return false; + const expected = normalizeProjectName(expectedProjectName); + + // EVERY MONETARY LINE CARRIES THE SAME CUSTOMER ID — tax included. + // + // The previous rule accepted three shapes it should not have, and each one + // let money onto a job nobody had attributed it to while the worker + // recorded the WHOLE gross against that project: + // + // - a non-tax line with a matching display NAME and no customer id. The + // "not attributed at all" guard read `!tax && !customerId && + // !customerName`, so a name alone satisfied it — and a name is not an + // identity. One named line then validated every other line beside it. + // - a TAX line with no customer ref at all. Tax was excluded from that + // guard entirely, so reclaimable sales tax could sit unassigned on a + // Purchase this check called fully attributed. + // - `ids.size <= 1`, which is satisfied by ZERO ids: a Purchase whose + // lines carried names and no ids passed with nothing pinned. + // + // So the id is derived from the lines themselves and then required of all + // of them: exactly one distinct id across every line, at least one line + // confirming that id belongs to the expected project BY NAME (the expected + // customer's own id is not resolvable on this branch — resolving it would + // CREATE a QBO customer on a replay path), and no line missing either. + const ids = new Set(); + let confirmedByName = false; + for (const line of lines) { + if (!line.readable) return false; + // Tax is money too. A line we cannot attribute is a line that has to + // go to a human, whichever account it posts to. + if (!line.customerId) return false; + ids.add(line.customerId); + if (line.customerName) { + if (normalizeProjectName(line.customerName) !== expected) return false; + confirmedByName = true; + } + } + // `=== 1`, not `<= 1`: zero ids is not agreement, it is an absence. + return ids.size === 1 && confirmedByName; +} + +/** + * THE ONE VENDOR COMPARISON IN THE CODEBASE. + * + * Exported because `book.ts` needs the identical question — "are these two + * spellings the same vendor?" — when it reconciles a receipt against an + * Expense the QBO importer already wrote. It used to compare byte-for-byte + * there while this compared case- and whitespace-insensitively, so QBO's + * canonical "Home Depot" and a receipt's " home depot " were the SAME + * vendor to the identity check and a `expense-conflict:vendor` park to the + * reconcile. Two normalizers is how that happens; one cannot. + */ +export function normalizeVendorName(name: string): string { + return name.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * COMPARE THE BOOKS AGAINST THIS DOCUMENT, and say which way the disagreement + * has to be resolved. See ExistingPurchaseCheck for the rule and why the two + * halves are split where they are. + * + * An UNREADABLE total or date is a `review`, never a pass. QBO returns both on + * every Purchase, so their absence means we are not looking at what we think we + * are looking at — and "I could not check" must not read the same as "I checked + * and it agrees" on the one path that decides what a real Expense records. + * + * A missing vendor or customer NAME is different: QBO documents the `name` on a + * ReferenceType as optional, so its absence is a fact about the response shape + * rather than about the books. Those compare only when a name is present, and + * the snapshot records what was (and was not) readable. + */ +export function compareExistingPurchase( + booked: BookedPurchaseValues, + input: Pick, +): ExistingPurchaseCheck { + const derive: string[] = []; + const review: string[] = []; + + const plannedCents = Math.round(Number(input.totalAmount) * 100); + if (booked.totalAmount === null) { + review.push("amount"); + } else if (Math.abs(Math.round(booked.totalAmount * 100) - plannedCents) > BOOKED_AMOUNT_TOLERANCE_CENTS) { + derive.push("amount"); + } + + if (booked.txnDate === null) { + review.push("date"); + } else if (booked.txnDate !== input.date) { + derive.push("date"); + } + + const plannedVendor = (input.vendor ?? "").trim(); + if (booked.vendor && plannedVendor && normalizeVendorName(booked.vendor) !== normalizeVendorName(plannedVendor)) { + derive.push("vendor"); + } + + // EVERY line, not "one name that matched". See attributionAgrees: a + // partly-coded Purchase used to pass on the strength of its coded half, and + // an entirely uncoded one skipped the check altogether. + if (!attributionAgrees(booked.lines, input.projectName)) review.push("project"); + + const plannedTaxCents = input.groups + .filter(g => g.tax === true) + .reduce((sum, g) => sum + Math.round(Number(g.amount) * 100), 0); + const bookedTaxCents = Math.round(booked.taxAmount * 100); + if (Math.abs(bookedTaxCents - plannedTaxCents) > BOOKED_AMOUNT_TOLERANCE_CENTS) { + review.push("tax"); + } + + const differences = [...review, ...derive].sort(); + if (review.length > 0) return { verdict: "review", differences, booked }; + if (derive.length > 0) return { verdict: "derive", differences, booked }; + return { verdict: "match", differences, booked }; +} + /** Same round-trip validation parseBackfillDate uses in the qbo-expenses/sync route. */ function isValidCalendarDate(value: unknown): value is string { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; @@ -412,6 +753,25 @@ export function attachmentFileName(rawFileName: string | undefined): string { return (rawFileName || "receipt").replace(/[\r\n"]/g, "") || "receipt"; } +/** + * The Attachable FileName idempotency actually runs on — derived from the + * receipt's OWN identity, never the caller-chosen display name. + * + * `fileId` is the Drive file id, or (for drive-less sources) the intake row's + * own cuid — see book.ts's `fileId = driveFileIdOf(row) ?? row.id` — so it is + * unique per receipt and cannot collide with a different document. Before + * this, `ensureAttachmentOnExistingPurchase` matched on the caller-supplied + * `fileName` alone, which is routinely identical across unrelated receipts + * (most phones name every photo "receipt.jpg" or worse). A Purchase that + * already carried a DIFFERENT receipt under that same generic name read as + * "already attached" for this one too, and the real bytes were never sent. + */ +export function stableAttachmentFileName(fileId: string, rawFileName: string | undefined): string { + const ext = (rawFileName || "").match(/\.[A-Za-z0-9]{1,8}$/)?.[0]?.toLowerCase() ?? ""; + const safeId = fileId.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 100) || "unknown"; + return attachmentFileName(`receipt-${safeId}${ext}`); +} + export async function defaultUploadAttachment( tokens: QBTokens, purchaseId: string, @@ -533,7 +893,7 @@ function planAttachmentUpload( if (!contentType) return null; if (!isValidBase64(input.fileBase64)) return null; if (Buffer.byteLength(input.fileBase64, "base64") > MAX_ATTACHMENT_BYTES) return null; - return { base64: input.fileBase64, contentType, fileName: attachmentFileName(input.fileName) }; + return { base64: input.fileBase64, contentType, fileName: stableAttachmentFileName(input.fileId, input.fileName) }; } /** @@ -1113,20 +1473,39 @@ async function createQBReceiptPurchaseUnderLock( const docNumber = input.fileId.slice(0, 21); const marker = `[gtr-file:${input.fileId}]`; + // Read once, up here, because the idempotency branch below needs the tax + // account to tell a reclaimable-tax line apart from an expense line. + const bankAccountId = process.env.QBO_RECEIPT_BANK_ACCOUNT_ID || BANK_ACCOUNT_ID_DEFAULT; + const expenseAccountId = process.env.QBO_RECEIPT_EXPENSE_ACCOUNT_ID || EXPENSE_ACCOUNT_ID_DEFAULT; + const taxAccountId = process.env.QBO_RECEIPT_TAX_ACCOUNT_ID || TAX_ACCOUNT_ID_DEFAULT; // Idempotency first — never re-create a Purchase for a file already // pushed. A DocNumber hit whose PrivateNote does NOT carry this file's // full marker is a genuine id collision (truncated to 21 chars — two // different Drive fileIds can share that prefix), not a re-send: refuse // rather than silently attach to the wrong Purchase. - const existing = await qbQueryFn<{ Id: string; PrivateNote?: string }>( + // + // `SELECT *`, not `Id, PrivateNote`. Two fields were enough to answer "is + // this our Purchase"; they were NOT enough to answer "does it say what this + // document says", and the caller went on to write an Expense from the OCR + // read regardless. A v1-cutover Purchase, or one posted from an earlier + // revision of the same Drive file, then left ProBuild's job cost carrying a + // number, a date or a job the books do not have. QBO cannot return a + // nested Line/EntityRef/TxnTaxDetail from a field list, so the whole entity + // is fetched — it is one row, and only on the replay path. + const existing = await qbQueryFn>( tokens, - `SELECT Id, PrivateNote FROM Purchase WHERE DocNumber = '${escapeQBString(docNumber)}'`, + `SELECT * FROM Purchase WHERE DocNumber = '${escapeQBString(docNumber)}'`, ); if (existing.length > 0) { - if (existing.length > 1 || !(existing[0].PrivateNote ?? "").includes(marker)) { + if (existing.length > 1 || !(String(existing[0].PrivateNote ?? "")).includes(marker)) { return { ok: false, reason: "docnumber-conflict", docNumber }; } + // THE PURCHASE EXISTS. Say so before doing anything else with it: the + // attachment re-check below is a QBO round trip that can fail, and the + // caller still has to know a Purchase is there. + await deps.onExistingPurchase?.(); + const booked = compareExistingPurchase(readBookedPurchase(existing[0], taxAccountId), input); // The Purchase exists, but that does NOT mean the receipt file made it // across. The common way to reach this branch is a first attempt whose // Purchase response was lost (timeout/kill) AFTER QBO committed it — @@ -1135,14 +1514,21 @@ async function createQBReceiptPurchaseUnderLock( // every retry took this same early return. Re-check and fill the gap. const attachment = await ensureAttachmentOnExistingPurchase( tokens, - existing[0].Id, + String(existing[0].Id), input, qbQueryFn, uploadAttachment, deadline, refreshTokensFn, ); - return { ok: true, qbPurchaseId: existing[0].Id, docNumber, alreadyExists: true, attachment }; + return { + ok: true, + qbPurchaseId: String(existing[0].Id), + docNumber, + alreadyExists: true, + attachment, + existing: booked, + }; } const projects = await listProjects(); @@ -1226,10 +1612,6 @@ async function createQBReceiptPurchaseUnderLock( throw error; } - const bankAccountId = process.env.QBO_RECEIPT_BANK_ACCOUNT_ID || BANK_ACCOUNT_ID_DEFAULT; - const expenseAccountId = process.env.QBO_RECEIPT_EXPENSE_ACCOUNT_ID || EXPENSE_ACCOUNT_ID_DEFAULT; - const taxAccountId = process.env.QBO_RECEIPT_TAX_ACCOUNT_ID || TAX_ACCOUNT_ID_DEFAULT; - const refSuffix = input.invoice ? ` · Invoice ${input.invoice}` : input.checkNumber @@ -1286,6 +1668,7 @@ async function createQBReceiptPurchaseUnderLock( if (isBudgetExhausted(deadline)) { throw new QBBudgetExhaustedError("Route budget exhausted before the QBO Purchase create"); } + await deps.onBeforeCreate?.(); const created = await qbCreateFn(tokens, payload, requestId); let attachment: ReceiptAttachmentStatus = "skipped"; diff --git a/src/lib/quickbooks-payments.ts b/src/lib/quickbooks-payments.ts index b52b4f7af..02a0573ff 100644 --- a/src/lib/quickbooks-payments.ts +++ b/src/lib/quickbooks-payments.ts @@ -3045,7 +3045,7 @@ export const automationSettingCursorStore: PaymentsSyncCursorStore = { * rows 0-99 are equally unverified but sat before the cursor, so the run * reported them as nothing left to do and called itself clean. */ -async function countUnvisited( +export async function countUnvisited( count: (where: Record) => Promise, state: { cursorId: string | null; originalCursor: string | null; wrapped: boolean }, ): Promise { @@ -3161,6 +3161,26 @@ export async function forEachPendingPage( await saveCursor(cursorId); } + // DID THE HANDLER ACTUALLY FINISH THIS PAGE? + // + // `lastCompletedId` is the furthest row it verified, and on a page cut + // short by a deadline or a QBO outage that is somewhere in the middle. + // The short-page branch below reads "fewer rows than we asked for" as + // "end of the collection" and RESETS the cursor to the top — so a + // 40-row final page that stopped after row 10 threw rows 11-40 away + // AND returned before `countRemaining`, so they were never counted as + // skipped either. The run reported a clean drain and thirty payments + // silently went unverified until the window happened to roll back over + // them. + const finishedPage = lastCompletedId !== null + && lastCompletedId === page[page.length - 1].id; + // Stopped mid-page: keep the cursor exactly where the handler got to + // and fall through to the counting path, which measures the unvisited + // tail (everything after `cursorId`) rather than assuming there is + // none. A full page that stopped early needs no special case — the + // outage and budget guards at the top of the loop catch it there. + if (!finishedPage) break; + // A short page means we reached the end of the collection. if (page.length < take) { if (startedFromCursor && !wrapped) { diff --git a/src/lib/receipt-intake/book.ts b/src/lib/receipt-intake/book.ts new file mode 100644 index 000000000..dbd72a63d --- /dev/null +++ b/src/lib/receipt-intake/book.ts @@ -0,0 +1,1385 @@ +/** + * Booking step — turn a READ intake row into a QuickBooks Purchase and a + * ProBuild Expense (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §4, book.ts). + * + * This writes REAL BOOKS. Two rules shape everything below: + * + * 1. There is exactly ONE QBO write core, `createQBReceiptPurchase` + * (src/lib/qbo-receipt-push.ts). It is imported and called directly — never + * re-implemented, and never reached by HTTP from this worker. Its + * idempotency (DocNumber = fileId.slice(0,21) + the [gtr-file:...] + * PrivateNote marker + a QBO requestid) is what makes a retry safe. + * 2. A 4xx-class business rejection is TERMINAL. Retrying a document QBO has + * already refused just burns the row's attempts and hides the problem; it + * goes to a human instead. Only transport-class failures retry. + * + * Every external effect is injected (`BookDependencies`), so the whole decision + * tree is testable without QuickBooks, Supabase, or a database + * (tests/receipt-intake-book.test.ts). No module mocking — CI is Node 20. + */ +import { matchCostCode } from "@/lib/project-match"; +import { receiptUrlRef } from "./receipt-url"; +import { + phaseConfidenceMin, + phaseSuggestionIsConfident, + QBO_ATTACHMENT_MAX_BYTES, +} from "./intake-core"; +import { dayKeyInTimeZone, startOfDateInTimeZone } from "@/lib/tz-date"; +// The SAME per-Purchase advisory lock the QBO importer takes — shared, not +// copied, so the two writers of one Purchase id cannot drift apart. +import { lockQboExpense } from "@/lib/qbo-expense-sync"; +import { + QBTimeoutError, + remainingBudgetMs, + type QBTokens, + type RouteDeadline, +} from "@/lib/quickbooks"; +import { + QboAccountConfigError, + QboPurchaseFaultError, + QboVendorDuplicateError, + // ONE vendor comparison for the whole pipeline — see normalizeVendorName. + normalizeVendorName, + type CreateQBReceiptPurchaseInput, + type CreateQBReceiptPurchaseResult, + type QboReceiptGroup, +} from "@/lib/qbo-receipt-push"; +import type { AutomationEventInput } from "@/lib/automation-events"; +import type { VerifiedBytes } from "./stored-object"; +import { backoffMs, MAX_BOOK_ATTEMPTS, preservedTaxWarning } from "./route-state"; + +/** The intake columns booking actually reads. Kept narrow so tests can build one by hand. */ +export interface BookableRow { + id: string; + source: string; + sourceRef: string; + dryRun: boolean; + projectId: string | null; + costCodeId: string | null; + suggestedCostCodeId: string | null; + /** The model's confidence in that phase suggestion, 0..1. */ + suggestedConfidence: number | null; + storagePath: string; + fileName: string | null; + mimeType: string; + vendor: string | null; + txnDate: Date | null; + totalCents: number | null; + taxCents: number | null; + docType: string | null; + refNumber: string | null; + memo: string | null; + /** What finalize recorded; every download of this row is checked against it. */ + fileSha256: string; + /** + * The token this pass claimed the row with. Every write is a CAS on it, so + * a worker whose claim was superseded cannot act on stale state. + */ + claimToken: string | null; + attempts: number; + /** Carries a previous attachment failure across a retry — see below. */ + lastError: string | null; + /** + * Whatever this row currently holds. It is not booking's to interpret in + * general — but the BOOKED write needs it to know whether a + * "tax-implausible" warning has to survive the transition (see + * preservedTaxWarning in route-state.ts). + */ + stateReason: string | null; + /** + * The durable dropped-tax-reading marker, written once by routing. + * `stateReason` cannot carry it: every deferred booking overwrites that + * column with its own reason. See preservedTaxWarning. + */ + taxWarning?: string | null; + /** + * True once a QBO create has been ATTEMPTED for this row. It is the only + * honest answer to "could a Purchase exist?", and it is what decides whether + * a park may release the strong key. + */ + sendAttempted: boolean; +} + +/** + * MIRRORS the private ATTACHABLE_CONTENT_TYPES / MAX_ATTACHMENT_BYTES in + * qbo-receipt-push.ts (:236, :202), which are not exported and which this + * branch must not modify. + * + * The duplication is deliberate and is the lesser evil: without a PREFLIGHT the + * QBO core happily creates the Purchase and then reports `attachment:"skipped"` + * for a file it cannot take, and `bookReceipt` marked that BOOKED. The result is + * a Purchase in the real books with no receipt attached — the one failure a + * bookkeeper cannot spot later, because the Purchase looks complete and nothing + * flags it. Every accepted .txt receipt hit this, as did anything between the + * old 15 MiB intake ceiling and QBO's 8 MiB attachment ceiling — which is why + * the two are now ONE constant. + * + * If either constant changes over there, this must change with it; the test + * asserts the two ceilings against each other so the gap cannot silently widen. + * + * The SIZE half is no longer a mirror at all: intake-core exports the one + * ceiling and every layer (bucket policy, /start, inspectStoredObject, this + * preflight) uses it, so a file that reaches here can always be attached. + */ +const QBO_ATTACHABLE_MIMES = new Set([ + "image/jpeg", "image/png", "image/gif", "image/webp", + "image/heic", "image/heif", "application/pdf", +]); +const MAX_QBO_ATTACHMENT_BYTES = QBO_ATTACHMENT_MAX_BYTES; + +/** + * Can QuickBooks take this file at all? Deterministic, so it is answered BEFORE + * the Purchase is created and no money moves on a document that would arrive + * without its evidence. + */ +export function attachmentBlocker(mimeType: string, byteLength: number): string | null { + const essence = mimeType.split(";")[0].trim().toLowerCase(); + if (!QBO_ATTACHABLE_MIMES.has(essence)) return `mime:${essence}`; + if (byteLength > MAX_QBO_ATTACHMENT_BYTES) return `size:${byteLength}`; + return null; +} + +/** + * A booking needs enough runway to finish what it starts. Two QuickBooks round + * trips (token refresh + the Purchase create, each with its own 20s fetch + * deadline) plus the attachment upload and the commit do not fit in a few + * seconds — and a booking cut off mid-flight is the worst outcome available: the + * Purchase may exist in the real books while the row never learns it did. + * Better to not start. + */ +export const MIN_BOOKING_BUDGET_MS = 25_000; + +export type BookResult = + /** Purchase + Expense exist and the row is BOOKED. */ + | { outcome: "booked"; qbPurchaseId: string; expenseId: string; alreadyExisted: boolean } + /** A switch is off: stay BOOKING, try again in an hour, spend NO attempt. */ + | { outcome: "deferred"; reason: "push-disabled" | "push-paused" | "out-of-budget" } + /** + * Terminal: a human must look at it. No further automatic attempt. + * + * `releaseStrongKey` mirrors the Apps Script v3.5 rule. A parked row keeps + * holding `dedupStrongKey` (the partial unique index covers every state + * except DUPLICATE/VOID), so if we park BEFORE ever reaching QuickBooks — + * the job has no estimate, the date is unusable — the key is being held by + * a document that never became a purchase. A corrected re-send of the same + * receipt would then be quarantined against a row that represents nothing. + * Release in exactly that case. Once a send was ATTEMPTED the key must be + * held: QBO may have created the Purchase and lost the response. + */ + | { outcome: "needs-review"; reason: string; releaseStrongKey: boolean } + /** + * This worker's claim was superseded. It wrote nothing and sent nothing; + * the row belongs to whoever holds the current token. + */ + | { outcome: "stale" } + /** Transport-class failure: attempts+1 and a backoff. */ + | { outcome: "retry"; attempts: number; nextRetryAt: Date; reason: string }; + +/** Structural subset of PrismaClient this module uses. */ +/** + * The Expense a crash-gap retry can find already sitting under this Purchase + * id — and every field the receipt has an opinion about. Selecting only `id` + * (which is what this used to do) is what made the blind link possible. + */ +export interface ExistingExpense { + id: string; + estimateId: string; + amount: unknown; + vendor: string | null; + date: Date | null; + costCodeId: string | null; + receiptUrl: string | null; +} + +export interface BookPrismaClient { + project: { + findUnique(args: any): Promise<{ + id: string; + name: string; + estimates: { id: string }[]; + } | null>; + }; + expense: { + findUnique(args: any): Promise; + create(args: any): Promise<{ id: string }>; + update(args: any): Promise; + }; + /** For the shared per-qbPurchaseId advisory lock — see lockQboExpense. */ + $queryRawUnsafe(query: string, ...values: unknown[]): Promise; + receiptIntake: { + update(args: any): Promise; + updateMany(args: any): Promise<{ count: number }>; + }; + $transaction(fn: (tx: BookPrismaClient) => Promise): Promise; +} + +export interface BookDependencies { + db: BookPrismaClient; + /** + * CAS on {id, state: BOOKING, claimToken} that persists sendAttempted. + * + * This is the LAST FENCE before QuickBooks, and the ONLY state check that + * matters at this point: it returns false when the row has been re-claimed + * or has moved on, and the booking then aborts having sent nothing — which + * is the point, because a zombie worker resuming with a stale view must not + * create a Purchase the live worker is about to create as well. + * + * Called from BOTH QBO-core hooks: immediately before the create, and when + * the idempotency query finds a Purchase already there. The second is not + * a send, but it is the same fact about the row (QuickBooks holds a + * Purchase for it), written under the same fence. + */ + markSendAttempted: (rowId: string, claimToken: string | null) => Promise; + /** The company's configured zone — Expense.date is a business calendar day. */ + companyTimeZone: () => Promise; + /** + * Is this cost code a phase of THIS project? Re-asked at booking because the + * project can change between READ and BOOKING. + */ + isCostCodeAllowed: (projectId: string, costCodeId: string) => Promise; + /** env master switch — opt-IN, exactly like the qbo-receipts/create route. */ + isPushEnabled: () => boolean; + /** Command Center pause switch (pause-only; fail-CLOSED on a read error). */ + isPushPaused: () => Promise; + /** + * RECEIPT_INTAKE_DRYRUN, read FRESH at booking time — not the row's + * persisted `dryRun` flag, which is snapshotted once at intake and never + * rechecked. A row claimed while the switch was off keeps dryRun=false + * forever, so it alone is not a kill switch: reverting the env var to stop + * live QBO writes would not stop that row. Both must agree for a write. + */ + isDryRunEnabled: () => boolean; + getTokens: (deadline?: RouteDeadline) => Promise; + createPurchase: ( + tokens: QBTokens, + input: CreateQBReceiptPurchaseInput, + deadline: RouteDeadline | undefined, + /** Invoked by the QBO core immediately before the create. */ + onBeforeCreate: () => Promise, + /** + * Invoked by the QBO core when it finds this file's Purchase ALREADY in + * QuickBooks — a path that never reaches the create, so `onBeforeCreate` + * does not fire on it. + */ + onExistingPurchase: () => Promise, + ) => Promise; + /** + * The invocation's ONE absolute deadline. Undefined = unbounded (tests). + * + * Deliberately the deadline OBJECT rather than a remaining-milliseconds + * number: a number is measured once and then decays silently, so a booking + * that spent 20s downloading its file still believed it had the budget it + * was handed on entry. Every check below recomputes from this instead. + */ + deadline?: RouteDeadline; + /** + * Reads the stored file back out of the private bucket. TAGGED, because a + * confirmed 404 and a transient storage fault must not book the same way. + */ + downloadBytes: (storagePath: string, expectedSha256: string) => Promise; + logEvent: (event: AutomationEventInput) => Promise; + now: () => Date; +} + +/** "drive:" carries the Drive id; everything else books under the intake cuid. */ +export function driveFileIdOf(row: Pick): string | null { + if (row.source !== "drive") return null; + const id = row.sourceRef.startsWith("drive:") ? row.sourceRef.slice("drive:".length) : ""; + return id || null; +} + +/** + * Port of sendToQBOviaAPI.js:129–178. GTR holds a reseller's permit, so sales + * tax paid to vendors without the certificate on file is recoverable via a + * state filing — when the read produced a tax line it becomes its own group so + * ProBuild posts it to "Reimbursable Sales Tax Paid" and the filing total is a + * one-click account report. + * + * Checks NEVER split tax (:148). An absent/unreadable tax (0) or a nonsense one + * (tax >= total) falls back to the single-line shape — a bad tax read must + * never block a booking. All math in integer cents; the two lines reconstruct + * the total EXACTLY. + */ +export function buildGroups( + docType: string | null, + totalCents: number, + taxCents: number | null, + refNumber: string | null, +): QboReceiptGroup[] { + const isCheck = String(docType || "receipt").toLowerCase() === "check"; + const tax = isCheck ? 0 : (taxCents ?? 0); + if (tax > 0 && tax < totalCents) { + return [ + { category: "Receipt (pre-tax)", amount: (totalCents - tax) / 100, lines: [] }, + { category: "Sales tax", amount: tax / 100, tax: true, lines: [] }, + ]; + } + return [{ + category: isCheck ? (refNumber ? `Check #${refNumber.replace(/^Check/, "")}` : "Check #?") : "Receipt", + amount: totalCents / 100, + lines: [], + }]; +} + +/** + * `Expense.amount` is the GROSS total paid, tax included — Justin's call + * (2026-09-01), overriding the plan's §4.5 "pre-tax" wording. + * + * The QBO Purchase still splits the tax onto its own reclaimable account; that + * is a QuickBooks-side concern and it is unchanged. But ProBuild's `Expense` + * has no tax column, and the expenses already imported from QBO + * (lib/qbo-expense-sync.ts) record the gross line total. Booking the pre-tax + * figure here would mean two intake paths writing the same table with two + * different meanings of `amount`, so job-cost and variance reports would + * silently under-count every receipt this pipeline touched. + * + * `ReceiptIntake.taxCents` keeps the split, so Phase 3 can add + * `Expense.taxAmount` and derive the pre-tax number without re-reading a single + * document. + */ +export function expenseAmountCents(_groups: QboReceiptGroup[], totalCents: number): number { + return totalCents; +} + +/** + * The tax that was ACTUALLY applied, read back off the built groups — 0 when + * `buildGroups` rejected the read (a check, or tax >= total). The audit row must + * record what posted, not what the model asked for; otherwise the sales-tax + * filing report reconciles against a number no Purchase ever carried. + */ +export function appliedTaxCents(groups: QboReceiptGroup[]): number { + return groups + .filter(g => g.tax === true) + .reduce((sum, g) => sum + Math.round(g.amount * 100), 0); +} + +/** @db.Date round-trips as UTC midnight; QBO wants a bare calendar day. */ +function toCalendarDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** + * A 4xx-class business rejection from QuickBooks. Retrying it cannot succeed — + * the document must go to a human, not back on the queue. + */ +function terminalReasonFor(error: unknown): string | null { + if (error instanceof QboPurchaseFaultError) { + return `qbo-fault:${error.faultCode ?? error.status}`; + } + if (error instanceof QboAccountConfigError) return "qbo-fault:account-config"; + if (error instanceof QboVendorDuplicateError) return "qbo-fault:vendor-duplicate"; + return null; +} + +/** + * Book one row. Never throws for an expected failure mode: every outcome is a + * BookResult the worker can persist, because a throw here would leave the row + * in BOOKING with no reason recorded. + */ +export async function bookReceipt(row: BookableRow, deps: BookDependencies): Promise { + const now = deps.now(); + const timeZone = await deps.companyTimeZone(); + + // Shadow mode is enforced by the WORKER, which never routes a dryRun row + // here. This second check exists because "no QBO calls in dry run" is the + // whole safety promise of the shadow week, and one guard in one caller is + // not a promise. BOTH the row's persisted flag and the CURRENT global + // switch gate the write — see isDryRunEnabled's doc comment for why the + // row flag alone cannot serve as a kill switch. + if (row.dryRun || deps.isDryRunEnabled()) { + return { outcome: "deferred", reason: "push-disabled" }; + } + + // 1. The same two switches the qbo-receipts/create route checks. Off or + // paused is NOT a failure of this document: stay BOOKING, retry in an + // hour, spend no attempt. + if (!deps.isPushEnabled()) return { outcome: "deferred", reason: "push-disabled" }; + if (await deps.isPushPaused()) return { outcome: "deferred", reason: "push-paused" }; + + // Runway check BEFORE anything else that could touch QuickBooks. Deferred, + // not retried: the document is fine and this costs it no attempt — the + // invocation simply ran out of room, and the next pass has a full budget. + const outOfRunway = () => + deps.deadline !== undefined && remainingBudgetMs(deps.deadline) < MIN_BOOKING_BUDGET_MS; + if (outOfRunway()) return { outcome: "deferred", reason: "out-of-budget" }; + + // Everything down to the QBO call is a PRE-SEND refusal for THIS attempt — + // but row.sendAttempted (persisted when the row was claimed) can already be + // true from an EARLIER attempt that reached QBO before a later re-read hit + // one of these checks (e.g. the estimate was deleted between attempts). + // parkedBeforeSend folds that in, so the strong key is handed back only + // when no attempt, past or present, may have created a Purchase. + if (!row.projectId) return parkedBeforeSend(row, "no-estimate"); + if (row.totalCents === null || row.totalCents <= 0) return parkedBeforeSend(row, "refund-or-zero"); + if (!row.txnDate) return parkedBeforeSend(row, "invalid-date"); + // Hoisted so the calendar day is computed ONCE and both the QBO TxnDate and + // the Expense.date instant are derived from the same value. + const calendarDay = toCalendarDate(row.txnDate); + + // 2. The project's LATEST estimate — the same "primary estimate" rule the + // v1 receipt-ingest endpoint uses (route.ts:69). Expense.estimateId is + // required, so a project with no estimate cannot be job-costed at all; + // that is terminal and costs no attempt. + const project = await deps.db.project.findUnique({ + where: { id: row.projectId }, + select: { + id: true, + name: true, + estimates: { orderBy: { createdAt: "desc" }, take: 1, select: { id: true } }, + }, + }); + if (!project) return parkedBeforeSend(row, "no-estimate"); + const estimateId = project.estimates[0]?.id; + if (!estimateId) return parkedBeforeSend(row, "no-estimate"); + + // 3. Category groups (tax split). + const groups = buildGroups(row.docType, row.totalCents, row.taxCents, row.refNumber); + + // 4. The one QBO write core. fileId = the Drive id when we have one, so a + // file v1 already booked keeps the SAME DocNumber and the create is a + // no-op rather than a second Purchase. + const fileId = driveFileIdOf(row) ?? row.id; + const isCheck = String(row.docType || "receipt").toLowerCase() === "check"; + + // NEVER a Purchase without its receipt. + // + // This used to pass `fileBase64: undefined` when the bytes could not be + // loaded and book anyway, which produces a QBO Purchase with no attachment + // — the one thing the bookkeeper cannot fix later, because by then the + // Purchase looks complete and nothing flags it. The receipt IS the evidence + // for the expense; a booking without it is worse than no booking. + // + // A transient storage fault retries (the document is fine, Supabase was + // not); an affirmative 404 is terminal and pre-send, so the strong key goes + // back for a corrected re-upload. + const download = await deps.downloadBytes(row.storagePath, row.fileSha256); + if (!download.ok) { + if (download.kind === "missing") return parkedBeforeSend(row, "receipt-bytes-missing"); + // The attachment about to ride along with a real Purchase is NOT the + // document this row was verified as. Refuse — a Purchase carrying the + // wrong receipt is worse than one carrying none. + if (download.kind === "sha-mismatch") return parkedBeforeSend(row, "content-changed"); + return retry(row, deps, now, `storage:${download.message}`); + } + const bytes = download.bytes; + + // RE-CHECKED after the download, which is the slowest thing before the + // send. An 8 MiB object over a slow link can eat the whole runway that the + // entry check just approved. + if (outOfRunway()) return { outcome: "deferred", reason: "out-of-budget" }; + + // PREFLIGHT, before anything is created. A format or size QBO cannot accept + // is a fact about this file, known now — so refuse now, rather than + // discovering it from `attachment:"skipped"` after a Purchase already + // exists in the real books without its receipt. + const blocker = attachmentBlocker(row.mimeType, bytes.length); + if (blocker) return parkedBeforeSend(row, `unsupported-attachment:${blocker}`); + + // Phase check ONE: immediately before the QBO create. The project can be + // reassigned while this row sits in the queue, and a stale phase should be + // caught before the books are touched, not only on the way to the Expense. + const phaseBeforeSend = await resolvePhase(row, project.id, deps); + + // NOTE: a previous attachment failure deliberately does NOT short-circuit + // here. createQBReceiptPurchase re-checks and re-uploads the file for an + // EXISTING Purchase (ensureAttachmentOnExistingPurchase), so the retry is + // the recovery — parking early would have made the stranded-receipt case + // permanent, which is the opposite of the intent. + + const input: CreateQBReceiptPurchaseInput = { + projectName: project.name, + docType: isCheck ? "check" : "receipt", + vendor: row.vendor ?? "", + date: calendarDay, + invoice: !isCheck && row.refNumber && row.refNumber !== "NoInv" ? row.refNumber : undefined, + checkNumber: isCheck && row.refNumber ? row.refNumber.replace(/^Check/, "") : undefined, + memo: row.memo ?? undefined, + totalAmount: row.totalCents / 100, + fileId, + fileName: row.fileName ?? undefined, + groups, + fileBase64: bytes.toString("base64"), + fileContentType: row.mimeType, + }; + + // TWO WAYS a Purchase can exist for this row by the time we are done: + // this attempt posted one, or the idempotency query found one an earlier + // attempt posted. Both mean the strong dedup key must be RETAINED when the + // row parks — releasing it lets a resubmission book the same receipt twice. + const sent = { attempted: false, purchaseKnownToExist: false }; + + let result: CreateQBReceiptPurchaseResult; + try { + // The SAME absolute deadline for both round trips, so a slow token + // refresh shortens the create rather than each helping itself to a + // fresh 20s. + const tokens = await deps.getTokens(deps.deadline); + // Last gate before the books are touched: the refresh may have consumed + // what was left. + if (outOfRunway()) return { outcome: "deferred", reason: "out-of-budget" }; + + // MARKED HERE — after the tokens and after the final budget check, and + // IMMEDIATELY before the create. + // + // Earlier was wrong in the direction that costs money to undo: a token + // refresh that threw, or a budget check that deferred, would have left + // sendAttempted=true on a row that never reached QuickBooks, and its + // strong key would then be held forever against a Purchase that does + // not exist. Persisted rather than in-memory, because the case the flag + // exists for is the process dying mid-create. + // The mark happens INSIDE createQBReceiptPurchase, immediately before + // the create — not here. + // + // Everything the QBO core does first can fail without any Purchase + // existing: the DocNumber query, the project match, ensureVendor, + // ensureCustomer, the account verification, the money validation. + // Marking before all of that meant a vendor-duplicate or an + // account-config fault left sendAttempted=true, and the row then held + // its dedup key forever against a Purchase that was never created. + // + // The hook is also the last ownership fence: a CAS on the claim token + // that THROWS when this worker has been superseded, which aborts the + // create so a zombie cannot post a Purchase the live worker is about to + // post as well. + result = await deps.createPurchase( + tokens, + input, + deps.deadline, + async () => { + const stillOurs = await deps.markSendAttempted(row.id, row.claimToken); + if (!stillOurs) throw new StaleClaimError(); + sent.attempted = true; + }, + // FENCED THE SAME WAY, for the same reason: the persisted flag is + // what a later pass reads, and a superseded worker must not write it + // (or carry on) at all. + async () => { + const stillOurs = await deps.markSendAttempted(row.id, row.claimToken); + if (!stillOurs) throw new StaleClaimError(); + sent.purchaseKnownToExist = true; + }, + ); + } catch (error) { + // A lost CAS from inside the create hook: nothing was sent. + if (error instanceof StaleClaimError) return { outcome: "stale" }; + const terminal = terminalReasonFor(error); + // A send WAS attempted — by THIS call (`sent`) or by an earlier one + // (row.sendAttempted, persisted at claim time) — QBO may hold a + // Purchase whose response we lost, so the key stays claimed even + // though the row is parked. + if (terminal) { + return { outcome: "needs-review", reason: terminal, releaseStrongKey: mayReleaseStrongKey(row, sent) }; + } + // QBTimeoutError, QBNotConnectedError, network/fetch errors, QBO + // 429/5xx and DB errors are all transport-class: try again later. + return retry(row, deps, now, describe(error), purchaseMayExist(sent)); + } + + if (!result.ok) { + // Every ok:false reason is a deterministic refusal, and — this is the + // part that was wrong — EVERY one of them is decided BEFORE qbCreateFn + // runs: project-not-matched, missing-vendor, invalid-date, + // invalid-group-amount, amount-mismatch, duplicate-name, + // overhead-*, and docnumber-conflict (which is the idempotency QUERY + // finding somebody else's Purchase, not one of ours). + // + // So THIS attempt created no Purchase, and holding the strong key would + // quarantine the corrected re-submission against a booking that never + // happened. Release it — UNLESS an earlier attempt already reached QBO + // (row.sendAttempted), in which case a Purchase may already exist and + // the key stays claimed. A THROWN fault is different — it can come from + // inside the create — and keeps the key. + return { + outcome: "needs-review", + reason: `qbo-fault:${result.reason}`, + releaseStrongKey: mayReleaseStrongKey(row, sent), + }; + } + + // The Purchase exists. If the receipt is not ON it, that is not a success — + // and this is checked on BOTH paths. + // + // The alreadyExists path was previously exempt, which is the path that + // MATTERS: it is reached by every retry after a lost response, i.e. exactly + // when a Purchase is most likely to be sitting there without its image. So + // the one case the check existed for was the one case it skipped. + // + // "already-attached" is a success: the file was put on by an earlier + // attempt. "failed:*" is an HTTP fault on the upload leg and is worth + // another pass (the QBO core re-uploads for an existing Purchase, so the + // retry genuinely recovers). "skipped" after a passing preflight means our + // mirrored ceilings have drifted from QBO's and a human must look. + if (result.attachment !== "attached" && result.attachment !== "already-attached") { + if (result.attachment === "skipped") { + return { outcome: "needs-review", reason: "unsupported-attachment:skipped", releaseStrongKey: false }; + } + // A `failed:<4xx>` or `failed:fault` is QBO REFUSING this file — a + // rejected format, an oversize body, a business-rule fault. Retrying it + // twenty times changes nothing except how long the Purchase sits in the + // books without its receipt, so it goes to a human on the first one. + // Only a transient class (5xx, a thrown network/abort error) is worth + // another pass. The key is retained either way: the Purchase EXISTS. + if (isTerminalAttachmentFailure(result.attachment)) { + return { + outcome: "needs-review", + reason: `attachment-refused:${result.attachment}`, + releaseStrongKey: false, + }; + } + return retry(row, deps, now, `${ATTACHMENT_FAILED_PREFIX}${result.attachment}`, purchaseMayExist(sent)); + } + + // WHEN THE PURCHASE WAS ALREADY IN THE BOOKS, THE BOOKS DECIDE. + // + // `alreadyExists` is not only the lost-response retry. It is also every + // v1-cutover document (the Apps Script posted the Purchase from its OWN + // read of the file) and every Drive revision that kept its fileId — and in + // both, QuickBooks may hold a total, a date, a vendor or a job that this + // pipeline's OCR pass does not agree with. Writing the Expense from the OCR + // read regardless left ProBuild's job cost carrying a number the books do + // not have, under a `qbPurchaseId` that says the two are the same document. + // + // The split is deliberate (see ExistingPurchaseCheck in qbo-receipt-push): + // amount/date/vendor are OCR noise on our side and QuickBooks is the booked + // truth for them; project and tax are ATTRIBUTION, so a mismatch parks with + // no Expense written at all. Nothing here rewrites QuickBooks either way — + // the Purchase is left exactly as it is. + // + // TOLERANCE IS FOR IDENTITY, NOT FOR VALUES — and conflating the two is the + // bug this block used to have. + // + // `compareExistingPurchase` allows a Purchase to differ by up to two cents + // on the amount or the tax, and to spell the vendor with different case and + // spacing, and still call it the SAME purchase. That tolerance exists so a + // rounding split or a capitalisation difference does not send a perfectly + // ordinary receipt to a human. It says nothing about which numbers to + // STORE. Adopting only on `derive` meant a verdict of `match` wrote the OCR + // total into job cost while QuickBooks held a figure one cent away, logged + // the OCR tax to the audit register as "what posted", and — on the + // importer-won crash gap — met the importer's QBO-sourced row at the exact + // comparison in reconcileExistingExpense and parked a receipt that was + // never wrong about anything. + // + // So: once the Purchase is IDENTIFIED, every value persisted or reported + // comes from what QuickBooks actually posted. `match` and `derive` adopt + // identically; only `differences` (the beyond-tolerance fields) is a + // reporting distinction. + let expenseTotalCents = row.totalCents; + let expenseCalendarDay = calendarDay; + let expenseVendor = row.vendor; + /** QBO's posted tax, in cents. Null until a Purchase is identified. */ + let bookedTaxCents: number | null = null; + let derivedNote = ""; + let derivedFields: string[] | undefined; + if (result.alreadyExists) { + const existing = result.existing; + if (existing.verdict === "review") { + // The key is RETAINED unconditionally: a Purchase provably exists. + return { + outcome: "needs-review", + reason: `${QBO_PURCHASE_MISMATCH_PREFIX}${existing.differences.join(",")}`, + releaseStrongKey: false, + }; + } + // Both surviving verdicts mean "this is the same purchase". A null here + // is unreachable for `derive` (a field it names was readable by + // definition) and possible for `match` only if QBO omitted the ref's + // display name — in which case the OCR value is all there is. + const booked = existing.booked; + if (booked.totalAmount !== null) expenseTotalCents = Math.round(booked.totalAmount * 100); + if (booked.txnDate !== null) expenseCalendarDay = booked.txnDate; + if (booked.vendor !== null) expenseVendor = booked.vendor; + // Never null: a tax reading QBO did not give would have been `review`. + bookedTaxCents = Math.round(booked.taxAmount * 100); + if (existing.differences.length > 0) { + derivedFields = existing.differences; + derivedNote = ` · ${existing.differences.join(", ")} taken from the existing QuickBooks Purchase`; + console.warn( + "[receipt-intake] expense derived from the existing QBO Purchase", + JSON.stringify({ rowId: row.id, qbPurchaseId: result.qbPurchaseId, differences: existing.differences }), + ); + } + } + + // 5. One transaction: the Expense and the row's BOOKED state land together + // or not at all. alreadyExists:true books the same way — that is the + // lost-response retry, and QBO's idempotency has already guaranteed + // there is exactly one Purchase. + const amountCents = expenseAmountCents(groups, expenseTotalCents); + // WHAT POSTED, and for an already-existing Purchase that is QBO's figure, + // not the one this pass built from the OCR read. `appliedTaxCents` reads + // back the groups WE were about to send; on the alreadyExists path those + // groups were never sent, so reporting them as "what posted" put a number + // in the sales-tax filing register that no Purchase ever carried. + const taxApplied = bookedTaxCents ?? appliedTaxCents(groups); + // RE-VALIDATE THE PHASE AGAINST THE FINAL PROJECT. + // + // Both the captured code and the model's suggestion were resolved while the + // row was being READ — and at that point the row may have had NO project at + // all (NEEDS_JOB), or a different one that a human then corrected. A cost + // code from the old project is not a phase of the new one, and posting an + // Expense against it puts real money on a phase that job does not have, + // which every variance report then reads as overspend on a line nobody + // budgeted. + // + // "The cost code exists" is not a permission (project-phases.ts:125), so + // this asks the same question the clock-in validation asks. A mismatch is + // NOT a failure: the receipt is fine and its total is right, so it books + // UNCODED and says why. A bookkeeper assigning a phase is routine; an + // expense silently attached to the wrong one is not. + // Phase check TWO: immediately before the Expense write, INSIDE the same + // window as the row's own commit. The create above is a network round trip + // that can take seconds, and the answer that matters is the one true when + // the money is recorded — a project reassignment that lands in between must + // not be written into job cost. + // EVERYTHING FROM HERE IS POST-SEND, so it is all inside the try. + // + // The phase re-check is a database round trip, and it used to sit OUTSIDE + // the protected block. A throw there — a pool timeout, a dropped + // connection — escaped bookReceipt entirely, so the worker's generic error + // handler parked the row from the snapshot it claimed with, and that + // snapshot says `sendAttempted: false`. The key was then released for a row + // that has a Purchase in the real books, and the next submission of the + // same receipt books it a second time. + try { + const phaseCheck = await resolvePhase(row, project.id, deps); + if (phaseCheck.costCodeId !== phaseBeforeSend.costCodeId) { + console.warn( + "[receipt-intake] phase changed across the QBO create", + JSON.stringify({ rowId: row.id, before: phaseBeforeSend.costCodeId, after: phaseCheck.costCodeId }), + ); + } + const costCodeId = phaseCheck.costCodeId; + const driveFileId = driveFileIdOf(row); + const receiptUrl = driveFileId + ? `https://drive.google.com/file/d/${driveFileId}/view` + // A STABLE reference, not a signed URL: the column outlives any link + // we could mint here (ten minutes later it is dead), and every reader + // mints its own from this — see resolveReceiptUrl. + : receiptUrlRef(row.storagePath); + // Hoisted: the reconcile compares against exactly what the create would + // have written, so the two must be the same expression. + const expenseDate = startOfDateInTimeZone(expenseCalendarDay, timeZone); + + // ONE OBJECT, THREE WRITES. + // + // The Expense, the intake row's BOOKED update and the audit event all + // have to say the same thing about the same money. They were three + // separate expressions reaching for three different variables, and + // they drifted exactly where it mattered: the audit reported + // `row.vendor` (the OCR spelling) while the Expense carried QBO's, and + // the intake row kept the OCR tax while both of the others recorded + // what actually posted. Building it once makes agreement structural + // rather than something three call sites have to remember. + const booked = { + vendor: expenseVendor || "Unknown", + // The same instant `dateOnly` would produce for this calendar day — + // written from `expenseDate` rather than importing that helper, + // because it lives in worker.ts and worker.ts imports this file. + // `ReceiptIntake.txnDate` is `@db.Date`, so the day is what lands. + txnDate: expenseDate, + date: expenseDate, + totalCents: amountCents, + taxCents: taxApplied, + }; + const docRef = isCheck + ? `Check #${(row.refNumber ?? "").replace(/^Check/, "") || "?"}${row.memo ? ` — "${row.memo}"` : ""}` + : (row.refNumber && row.refNumber !== "NoInv" ? `Invoice ${row.refNumber}` : "Receipt"); + + // The attribution the Expense ACTUALLY ends up carrying. Decided inside + // the transaction (only the reconcile knows whether the row already had + // a phase) and carried out here, because the audit event has to report + // what was persisted rather than what this pass proposed. + let effective: EffectiveAttribution = costCodeId + ? { costCodeId, costCodeSource: "receipt", preserved: false } + : { costCodeId: null, costCodeSource: "none", preserved: false }; + const expenseId = await deps.db.$transaction(async tx => { + // THE SAME LOCK THE QBO IMPORTER TAKES, before this Purchase id is + // read at all. + // + // `qbo-expense-sync` serializes every writer of one Purchase id on + // this key before it reads or writes the Expense. This path writes + // an Expense under the same key and was not taking it, so the + // importer could create the row in the gap between the lookup + // below and the link — and the two ended up disagreeing about the + // same money. Shared as a function, not a copied string. + await lockQboExpense(tx, result.qbPurchaseId); + // A retry after a crash between the Purchase and this commit finds + // its own Expense here (qbPurchaseId is @unique) — create it twice + // and the insert would fail on that constraint anyway. It is ALSO + // where the importer's row turns up: QBO expense sync imports the + // Purchase on its own schedule, so a worker retry after a crash + // routinely finds an Expense that this receipt never wrote. + const existing = await tx.expense.findUnique({ + where: { qbPurchaseId: result.qbPurchaseId }, + select: { + id: true, estimateId: true, amount: true, vendor: true, + date: true, costCodeId: true, receiptUrl: true, + }, + }); + if (existing) { + // NEVER a blind link. See reconcileExistingExpense. + // From the SAME `booked` object the writes use: the reconcile + // has to compare against the values that will actually be + // persisted, or it is judging a row against numbers nobody + // ever stores. + const verdict = reconcileExistingExpense(existing, { + estimateId, + amountCents: booked.totalCents, + vendor: booked.vendor, + date: booked.date, + calendarDay: expenseCalendarDay, + timeZone, + costCodeId, + receiptUrl, + }); + if (verdict.conflicts.length > 0) { + throw new ExpenseConflictError(verdict.conflicts); + } + effective = verdict.attribution; + // Fill what the importer could not know. `costCodeId` and + // `receiptUrl` are not in QboExpenseWrite at all, so a + // non-null value on an imported row can only have been put + // there by a person (or an earlier receipt) — which is why the + // rule is fill-when-null and never overwrite. + if (Object.keys(verdict.fill).length > 0) { + await tx.expense.update({ where: { id: existing.id }, data: verdict.fill }); + } + } + const expense = existing ?? await tx.expense.create({ + data: { + estimateId, + costCodeId, + amount: booked.totalCents / 100, + vendor: booked.vendor, + // RE-ANCHORED at write time. `txnDate` is a @db.Date column + // and round-trips as UTC midnight, so writing it straight + // into Expense.date (a full timestamp) records 5pm the + // PREVIOUS day in Pacific — and every job-cost and variance + // report that bounds by local midnight then counts the + // expense in the wrong period. The intake row keeps the + // calendar day; this makes the instant match it. + date: booked.date, + // Booked with a qbPurchaseId already set — the Purchase is + // live in QuickBooks by the time this row commits, so this + // Expense is QBO-managed from birth, exactly like a QBO + // import. `assertExpenseMutableOutsideQbo` (qbo-expense-guard.ts) + // rejects approve/edit/delete on anything carrying a + // qbPurchaseId, and the bookkeeper queue (manager/receipts/page.tsx) + // only lists `status: "Pending"` rows as actionable. Leaving + // this "Pending" would put a QBO-managed row in that + // actionable queue with no route able to act on it — and + // a later QBO sync flipping it to "Reviewed" would look + // like human review that never happened. "Reviewed" keeps + // it out of the actionable queue and matches every other + // QBO-linked Expense. + status: "Reviewed", + receiptUrl, + qbPurchaseId: result.qbPurchaseId, + description: + `[Receipt intake] ${docRef}` + + phaseCheck.note + + (taxApplied > 0 ? ` · incl. $${(taxApplied / 100).toFixed(2)} sales tax` : "") + + derivedNote + + ` · booked to QuickBooks`, + }, + select: { id: true }, + }); + // CAS again inside the commit. If the row was re-claimed between + // the create and here, this transaction rolls back — including the + // Expense — and the successor retries: QBO's DocNumber idempotency + // returns the SAME Purchase, so it books once, under one owner. + // Completing a BOOKED write from a stale worker would leave two + // owners disagreeing about the row. + const claimed = await tx.receiptIntake.updateMany({ + where: { id: row.id, state: "BOOKING", claimToken: row.claimToken }, + data: { + state: "BOOKED", + // Every OTHER value this column carries at BOOKING (a + // defer reason like "push-paused", a retry note) is + // transient and must not survive into BOOKED — but a + // dropped-tax-reading warning is a fact about the + // DOCUMENT, not about why booking was delayed, and must. + stateReason: preservedTaxWarning(row), + qbPurchaseId: result.qbPurchaseId, + expenseId: expense.id, + bookedAt: now, + lastError: null, + nextRetryAt: null, + // THE BOOKED VALUES, PERSISTED — the same object the + // Expense and the audit event are built from. + // + // "QuickBooks is authoritative for an existing Purchase" + // was only half true: booking DERIVED the total, vendor, + // date and tax from QBO and wrote them to the Expense, but + // left this row carrying the OCR read. So `taxCents` — the + // column Phase 3's sales-tax reporting is specified to + // read — kept a figure no Purchase ever posted, and the + // row and its own Expense disagreed under a qbPurchaseId + // asserting they are one document. + // + // BOOKED OVERWRITES THE EXTRACTED VALUES, deliberately. + // There is no `extracted*` column pair on this model, and + // none is needed: `readJson` holds the raw model response + // verbatim and is never rewritten, so the OCR original + // remains auditable after the row records what posted. + vendor: booked.vendor, + txnDate: booked.txnDate, + totalCents: booked.totalCents, + taxCents: booked.taxCents, + // Ownership is released by the write that completes the + // transition — a booked row is nobody's to hold. + claimToken: null, + claimedAt: null, + }, + }); + if (claimed.count === 0) throw new StaleClaimError(); + return expense.id; + }); + + // Audit row so the /automation register keeps seeing v2 bookings + // alongside the bot's. Fire-and-forget by contract — never fails a + // booking that already happened. + await deps.logEvent({ + kind: "receipt-push", + status: result.alreadyExists ? "already-exists" : "created", + source: "intake-worker", + // WHAT THE EXPENSE GOT, not what the read said. The audit used + // to report the OCR spelling while the Expense carried QBO's. + vendor: booked.vendor, + projectName: project.name, + docNumber: result.docNumber, + fileName: row.fileName ?? undefined, + amountCents: booked.totalCents, + // What POSTED, not what was requested — buildGroups rejects a tax + // read on a check or when tax >= total, and the filing report has + // to reconcile against the Purchase. + taxCents: booked.taxCents, + detail: { + // `fileId` means a DRIVE file id — logAutomationEvent copies it + // into the typed `driveFileId` column, which the cutover reads + // to decide whether v1 already booked a document. Emitting an + // intake cuid there filled that column with ids no Drive query + // can ever match, and quietly widened what "v1 booked this" + // could mean. Non-Drive rows carry their id in `intakeId`, + // which every row has anyway. + ...(driveFileId ? { fileId: driveFileId } : {}), + qbPurchaseId: result.qbPurchaseId, + intakeId: row.id, + expenseId, + sourceRef: row.sourceRef, + // THE PERSISTED value, not the one this pass picked. When a + // human's phase was already on the row it stands, and an audit + // row naming the worker's choice would assert a cost code that + // was never applied to anything. + costCodeId: effective.costCodeId, + costCodeSource: effective.costCodeSource, + // Explicit, so "the worker's pick lost" is greppable rather + // than something a reader has to infer from two ids. + phasePreserved: effective.preserved || undefined, + // Which fields (if any) this Expense took from the books rather + // than from the read. Absent on the normal path. + qboDerivedFields: derivedFields, + // Carried through so the Command Center can show HOW confident + // the phase pick was, and so a low-confidence run is auditable + // after the fact rather than only at review time. Against the + // EFFECTIVE code: a confidence score attached to a suggestion + // that was not the one persisted describes nothing. + suggestedConfidence: effective.costCodeId && effective.costCodeId === row.suggestedCostCodeId + ? row.suggestedConfidence + : undefined, + phaseRejected: phaseCheck.rejected || undefined, + }, + }).catch(() => { /* audit only */ }); + + return { + outcome: "booked", + qbPurchaseId: result.qbPurchaseId, + expenseId, + alreadyExisted: result.alreadyExists, + }; + } catch (error) { + // A lost CAS is not a fault: the successor owns this row and will book + // it. Say so rather than spending an attempt on it. + if (error instanceof StaleClaimError) return { outcome: "stale" }; + // The Expense already under this Purchase id says something different + // about the money. Retrying cannot resolve that — the next pass finds + // the same row and the same disagreement — so it goes to a person. + // + // The key is RETAINED: the Purchase provably exists (we either created + // it or the idempotency query found it), and releasing it would let a + // resubmission of the same receipt book a second one. + if (error instanceof ExpenseConflictError) { + return { + outcome: "needs-review", + reason: `${EXPENSE_CONFLICT_PREFIX}${error.fields.join(",")}`, + releaseStrongKey: false, + }; + } + // The Purchase EXISTS at this point. Retrying is correct and safe: the + // DocNumber lookup will find it and return alreadyExists:true — and the + // key must be RETAINED, which is why this attempt's send flag is passed + // rather than the row's stale copy. + return retry(row, deps, now, describe(error), purchaseMayExist(sent)); + } +} + +/** + * Does QuickBooks (possibly) hold a Purchase for this row after this attempt? + * + * `attempted` covers a create we issued — including one whose response we lost. + * `purchaseKnownToExist` covers the idempotency query finding one an earlier + * attempt posted, which is the path that never reaches the create at all. + */ +function purchaseMayExist(sent: { attempted: boolean; purchaseKnownToExist: boolean }): boolean { + return sent.attempted || sent.purchaseKnownToExist; +} + +/** + * Centralized strong-key release decision for every needs-review path. + * + * A Purchase may exist for this row because of THIS attempt's send (`sent`) + * or because of an EARLIER attempt's send — `row.sendAttempted`, persisted + * when the row was claimed, so it survives even when this attempt never + * reaches QBO at all (a re-read hitting a deleted estimate, a missing + * object, or any other pre-send/ok:false refusal on a retry). The strong key + * may only be released when neither is true. + */ +function mayReleaseStrongKey( + row: BookableRow, + sent: { attempted: boolean; purchaseKnownToExist: boolean } = { attempted: false, purchaseKnownToExist: false }, +): boolean { + return !(row.sendAttempted || purchaseMayExist(sent)); +} + +function describe(error: unknown): string { + if (error instanceof QBTimeoutError) return "QBTimeoutError"; + if (error instanceof Error) return `${error.name}: ${error.message}`.slice(0, 400); + return "UnknownError"; +} + +/** + * RECONCILE A RECEIPT AGAINST AN EXPENSE THAT IS ALREADY THERE. + * + * The row under this `qbPurchaseId` is not necessarily one we wrote. The + * expected case is a crash-gap race: the worker creates the QBO Purchase, dies + * before its commit, and QBO expense sync imports that Purchase before the + * retry comes round. The imported row is correct about the money (it came from + * the same Purchase) and knows nothing about this receipt — no cost code, no + * receiptUrl, because `QboExpenseWrite` carries neither column. Legacy rows, + * and rows a person has edited, can disagree about more. + * + * Blindly linking it — which is what selecting `{ id: true }` and taking the + * `existing ?? create` branch amounted to — marked the intake row BOOKED + * against a job-cost row that might name a different job, a different amount, + * or no phase at all, under an id that asserts the two are the same document. + * + * So the fields split by what a disagreement MEANS: + * + * MONEY AND IDENTITY (estimate, amount, vendor, date) — the receipt and the + * Expense are supposed to be two views of one Purchase, and by the time this + * runs the receipt's own values have already been derived FROM QuickBooks + * whenever the Purchase pre-existed (see the `alreadyExists` block above). + * A populated field that still disagrees is a real contradiction about real + * money, and nothing here can safely pick a winner: it parks for a human. + * A NULL one (vendor and date are nullable) is missing attribution and is + * filled from the receipt. + * + * ATTRIBUTION (costCodeId, receiptUrl) — filled when null, never overwritten. + * The importer cannot write either column, so a value there came from a + * person or from an earlier receipt, and theirs is the answer that stands. + * That IS the human-source predicate for this schema; there is no provenance + * column to consult and no `notHumanCodedExpenseWhere` helper in this + * codebase (checked: the name appears nowhere), so "the importer could not + * have written this" is the honest test. + * + * Pure, so the whole truth table is a unit test rather than a race. + */ +export interface ReceiptExpenseValues { + estimateId: string; + amountCents: number; + vendor: string; + date: Date; + /** The company calendar day the receipt is filed under, e.g. "2026-09-01". */ + calendarDay: string; + timeZone: string; + costCodeId: string | null; + receiptUrl: string; +} + +/** + * What the Expense will ACTUALLY carry once this reconcile is applied. + * + * Returned rather than re-derived at the audit site, because the caller cannot + * work it out: the reconcile is the only thing that knows whether the row + * already had a phase. The booking event used to log the value the WORKER + * picked whatever happened, so a receipt whose phase was preserved from a + * human's earlier choice produced an audit row asserting a cost code that was + * never applied to anything. + */ +export interface EffectiveAttribution { + costCodeId: string | null; + /** Where the persisted value came from. */ + costCodeSource: "receipt" | "existing" | "none"; + /** True when a value already on the row displaced the one this pass chose. */ + preserved: boolean; +} + +export interface ExpenseReconcile { + conflicts: string[]; + fill: Record; + attribution: EffectiveAttribution; +} + +export function reconcileExistingExpense( + existing: ExistingExpense, + receipt: ReceiptExpenseValues, +): ExpenseReconcile { + const conflicts: string[] = []; + const fill: Record = {}; + + if (existing.estimateId !== receipt.estimateId) conflicts.push("estimate"); + if (Math.round(Number(existing.amount) * 100) !== receipt.amountCents) conflicts.push("amount"); + + // Nullable, so an absence is missing attribution rather than a contradiction. + // + // THE SAME NORMALIZER the identity check uses. Comparing byte-for-byte here + // while `compareExistingPurchase` compared case- and whitespace-insensitively + // meant QBO's canonical "Home Depot" and the receipt's " home depot " + // were one vendor to the check that decided these are the same purchase and + // two vendors to the check that decided whether to link them — so the + // importer-won crash gap parked a receipt nothing was wrong with. (The + // receipt's own vendor is now QBO's display name on that path anyway; this + // is what keeps the two answers consistent for every other path, and for a + // legacy row the importer never touched.) + if (!existing.vendor) fill.vendor = receipt.vendor; + else if (normalizeVendorName(existing.vendor) !== normalizeVendorName(receipt.vendor)) { + conflicts.push("vendor"); + } + + if (!existing.date) fill.date = receipt.date; + else if (!sameCalendarDay(existing.date, receipt)) conflicts.push("date"); + + // FILL-ONLY. Never a conflict: a phase somebody chose is an answer, not a + // contradiction about money, and overwriting it is the one outcome that + // loses information nobody can recover. + if (!existing.costCodeId && receipt.costCodeId) fill.costCodeId = receipt.costCodeId; + if (!existing.receiptUrl) fill.receiptUrl = receipt.receiptUrl; + + return { conflicts, fill, attribution: effectiveAttribution(existing, receipt) }; +} + +function effectiveAttribution( + existing: ExistingExpense, + receipt: ReceiptExpenseValues, +): EffectiveAttribution { + if (existing.costCodeId) { + return { + costCodeId: existing.costCodeId, + costCodeSource: "existing", + // Only a CONTEST counts as preserved: a receipt with no phase to + // offer, or one offering the same phase, displaced nothing. + preserved: !!receipt.costCodeId && receipt.costCodeId !== existing.costCodeId, + }; + } + return receipt.costCodeId + ? { costCodeId: receipt.costCodeId, costCodeSource: "receipt", preserved: false } + : { costCodeId: null, costCodeSource: "none", preserved: false }; +} + +/** + * THE TWO WRITERS ANCHOR A CALENDAR DAY DIFFERENTLY, and comparing instants + * would call every imported row a conflict. + * + * `qbo-expense-sync` writes `new Date(`${txnDate}T00:00:00.000Z`)` — UTC + * midnight, a bare marker for the QBO calendar day. This file writes + * `startOfDateInTimeZone(day, timeZone)` — the company's local midnight, which + * is 07:00 or 08:00Z for Pacific. Both mean "the 3rd"; their `getTime()`s + * differ by hours. + * + * So the ANCHOR IS DETECTED rather than guessed at, and accepting "either + * reading matches" would be wrong: UTC midnight on the 4th reads as the 3rd in + * Pacific, so an off-by-one-day row would sail through. A value that is + * exactly UTC midnight is the importer's marker and means that UTC date; + * anything else is a real instant and means the day it falls on locally. + * (In a UTC company both readings coincide anyway.) + */ +function sameCalendarDay(stored: Date, receipt: ReceiptExpenseValues): boolean { + const isUtcMidnightMarker = stored.getTime() % 86_400_000 === 0; + const storedDay = isUtcMidnightMarker + ? stored.toISOString().slice(0, 10) + : dayKeyInTimeZone(stored, receipt.timeZone); + return storedDay === receipt.calendarDay; +} + +/** + * The existing Expense contradicts this receipt about money or attribution. + * + * Terminal for the pass and the strong key is RETAINED: a Purchase provably + * exists in QuickBooks, so releasing the key would let a resubmission book it + * a second time. A person compares the two and decides. + */ +/** + * The park reason a reviewer sees, and the prefix the queue filters on. + * Distinct from QBO_PURCHASE_MISMATCH_PREFIX: that one is "QuickBooks and the + * read disagree", this one is "our own job-cost row and the read disagree". + */ +export const EXPENSE_CONFLICT_PREFIX = "expense-conflict:"; + +class ExpenseConflictError extends Error { + constructor(readonly fields: string[]) { + super(`existing expense disagrees on ${fields.join(",")}`); + this.name = "ExpenseConflictError"; + } +} + +/** Thrown inside the commit transaction when the claim token no longer matches. */ +class StaleClaimError extends Error { + constructor() { + super("the claim was superseded"); + this.name = "StaleClaimError"; + } +} + +/** Marks a retry as "the Purchase exists but its receipt did not attach". */ +export const ATTACHMENT_FAILED_PREFIX = "attachment-failed:"; + +/** + * Marks a park as "QuickBooks already holds this Purchase and it does not say + * what this document says". Its own reason, not folded into `qbo-fault:`, + * because nothing is wrong with QuickBooks: the books and the read disagree + * about the job or the tax split, and only a human may choose between them. + * + * Deliberately NOT in RECOVERABLE_PARK_REASONS — a re-upload of the same bytes + * changes nothing, and dragging the row back would re-read it into the same + * disagreement. + */ +export const QBO_PURCHASE_MISMATCH_PREFIX = "qbo-purchase-mismatch:"; + +/** + * Is this attachment failure QBO refusing the file, rather than a blip? + * + * `failed:` carries the HTTP status; `failed:fault` is an Intuit + * business-rule rejection; `failed:` comes from a thrown error and + * is transient by nature (AbortError, TypeError from fetch, ...). + */ +export function isTerminalAttachmentFailure(attachment: string): boolean { + const detail = attachment.slice("failed:".length); + if (detail === "fault") return true; + const status = Number(detail); + return Number.isFinite(status) && status >= 400 && status < 500; +} + +/** + * Which phase (if any) this Expense may carry, checked against the project the + * row will ACTUALLY book to. + */ +async function resolvePhase( + row: BookableRow, + projectId: string, + deps: BookDependencies, +): Promise<{ costCodeId: string | null; note: string; rejected: string | null }> { + // A human's explicit pick outranks the model's suggestion, but neither is + // trusted without the project check. + const explicit = row.costCodeId ?? null; + const candidate = explicit ?? row.suggestedCostCodeId ?? null; + if (!candidate) return { costCodeId: null, note: "", rejected: null }; + + // THE CONFIDENCE THE PROMPT ASKS FOR IS NOW THE CONFIDENCE THAT DECIDES. + // + // read.ts tells the model a low number "sends the receipt to a human"; this + // is where that becomes true. Below the threshold (or with no number at + // all) the Expense books UNCODED and the suggestion is recorded as + // REJECTED — the same signal the wrong-job branch below raises, so it + // reaches the queue and the audit event through one path rather than two. + // Checked before the project round trip: a suggestion we will not apply is + // not worth a database call. + if (!explicit && !phaseSuggestionIsConfident(row.suggestedConfidence)) { + const stated = typeof row.suggestedConfidence === "number" + ? row.suggestedConfidence.toFixed(2) + : "none stated"; + return { + costCodeId: null, + note: ` · phase suggestion withheld (confidence ${stated} < ${phaseConfidenceMin()}) — assign one`, + rejected: candidate, + }; + } + + const allowed = await deps.isCostCodeAllowed(projectId, candidate); + if (allowed) { + const fromSuggestion = !row.costCodeId && candidate === row.suggestedCostCodeId; + const confidence = row.suggestedConfidence; + const note = + fromSuggestion && typeof confidence === "number" + ? ` · phase suggested (confidence ${confidence.toFixed(2)})` + : ""; + return { costCodeId: candidate, note, rejected: null }; + } + + return { + costCodeId: null, + note: " · phase cleared (not a phase of this job) — assign one", + rejected: candidate, + }; +} + +/** + * A refusal reached WITHOUT any QBO call in THIS attempt — the strong key goes + * back, UNLESS row.sendAttempted (persisted at claim time) means an EARLIER + * attempt may already hold a Purchase for this row. + * + * The rule is about the SEND, not about the reason: any terminal park that + * provably created no Purchase — this attempt or any prior one — releases the + * key, whatever the reason string says. Holding it makes a corrected + * resubmission collide with a row that never became a purchase, and the + * reviewer then has two stuck rows instead of one. + */ +function parkedBeforeSend(row: BookableRow, reason: string): BookResult { + return { outcome: "needs-review", reason, releaseStrongKey: mayReleaseStrongKey(row) }; +} + +function retry( + row: BookableRow, + deps: BookDependencies, + now: Date, + reason: string, + /** + * Whether THIS attempt learned that a Purchase may exist — either because it + * reached the create, or because the idempotency query found one already + * there. `row.sendAttempted` is the value read when the row was CLAIMED, so + * it is stale the moment the fenced mark runs, and a failure after that + * point (the attachment leg, the Expense commit) judged on it alone would + * wrongly release the key of a row that really does have a Purchase. + */ + purchaseMayExistNow = false, +): BookResult { + const sendAttempted = row.sendAttempted || purchaseMayExistNow; + const attempts = row.attempts + 1; + // `>=`, so MAX_BOOK_ATTEMPTS reads as "20 attempts in total" rather than 21. + if (attempts >= MAX_BOOK_ATTEMPTS) { + // Keyed on whether a send ever happened, not on the assumption that + // reaching the retry limit implies one. A row can exhaust its attempts + // entirely on storage faults, having never touched QuickBooks — and + // holding its key then quarantines the corrected resend against nothing. + return { outcome: "needs-review", reason: "max-retries", releaseStrongKey: !sendAttempted }; + } + return { + outcome: "retry", + attempts, + nextRetryAt: new Date(now.getTime() + backoffMs(attempts)), + reason, + }; +} + +/** + * Resolve the model's phase suggestion to a cost-code id, using the same + * matcher the v1 ingest uses. Called by the READ step so booking stays + * database-light and the suggestion is visible in the queue before it books. + */ +export function resolveSuggestedCostCodeId( + suggestedPhaseCode: string, + costCodes: { id: string; code: string; name: string }[], +): string | null { + if (!suggestedPhaseCode) return null; + return matchCostCode(suggestedPhaseCode, costCodes)?.id ?? null; +} diff --git a/src/lib/receipt-intake/bucket.ts b/src/lib/receipt-intake/bucket.ts new file mode 100644 index 000000000..389b79956 --- /dev/null +++ b/src/lib/receipt-intake/bucket.ts @@ -0,0 +1,332 @@ +/** + * The intake feature's OWN private bucket. + * + * Intake objects used to live in `secure-docs` alongside signed contracts, + * e-signatures and invoice PDFs. Three reasons that was wrong, and all of them + * are about blast radius rather than tidiness: + * + * 1. The size and MIME ceilings are set PER BUCKET in Supabase, and the + * two-step upload goes straight to a signed URL that never passes through + * this server — so the bucket is the only place a 400 MB write or an + * executable can actually be refused. `secure-docs` cannot carry a receipt + * policy without imposing it on every other document type. + * 2. A signed upload URL is a write capability. Issuing one against the bucket + * that also holds countersigned contracts means a path-handling bug in the + * intake code is a write into the contract store. + * 3. Cleanup deletes objects. The orphan sweep runs unattended against paths + * read out of an event log; it must not be able to reach anything but + * receipts. + * + * Everything intake does with storage goes through this module, so there is one + * place that names the bucket and one place to audit. + */ +import type { SupabaseClient } from "@supabase/supabase-js"; +// Only the SIGNAL-BOUND factory: the unsignalled singleton is what let a hung +// request eat an invocation, so this file must not be able to reach for it. +import { getSupabaseWithSignal } from "@/lib/supabase"; +import { remainingBudgetMs, type RouteDeadline } from "@/lib/quickbooks"; +import { isNotFoundError, type DocBytesResult } from "@/lib/secure-storage"; +import { ACCEPTED_MIME_TYPES } from "./file-type"; +import { MAX_STORED_BYTES } from "./intake-core"; + +export const RECEIPT_BUCKET = "receipt-intake"; + +/** + * NO STORAGE CALL MAY OUTLIVE THE INVOCATION THAT MADE IT. + * + * Every function in this file used to `await` Supabase with no timeout and no + * abort signal, and the worker's own `shouldStop` only runs BETWEEN operations. + * So a single hung request ate the whole 60-second lifetime: the platform + * killed the function mid-pass, the rows it had claimed never reached the + * release path, and they sat leased for ten minutes — and because the same + * object headed the queue next time, the same request hung the next run too. + * One stalled object could stall the pipeline indefinitely. + * + * Two mechanisms, because either alone is not enough: + * - an AbortSignal threaded into the client's fetch, so the request is + * genuinely cancelled rather than left running; + * - a timer that settles the promise, because an abort that the client + * swallows would otherwise still hang the await. + * + * The budget is derived from the caller's RouteDeadline, so a call late in a + * pass gets only what is actually left rather than a fresh fixed timeout that + * could straddle the platform ceiling. With no deadline (tests, scripts) the + * default applies. + */ +export const STORAGE_CALL_MAX_MS = 15_000; +/** Below this there is no point starting a storage call at all. */ +export const STORAGE_CALL_MIN_MS = 500; + +/** Tag for a call that ran out of budget. Callers map it to their transient path. */ +export const STORAGE_TIMEOUT_MESSAGE = "storage-timeout"; + +export class StorageTimeoutError extends Error { + name = "StorageTimeoutError"; + constructor(op: string) { + super(`${STORAGE_TIMEOUT_MESSAGE}:${op}`); + } +} + +/** Name-based, like every other error guard here — see CLAUDE.md. */ +export function isStorageTimeout(error: unknown): boolean { + return error instanceof Error && error.name === "StorageTimeoutError"; +} + +export function storageBudgetMs(deadline?: RouteDeadline): number { + const left = remainingBudgetMs(deadline); + if (!Number.isFinite(left)) return STORAGE_CALL_MAX_MS; + return Math.min(STORAGE_CALL_MAX_MS, Math.max(0, Math.floor(left))); +} + +/** + * Run one storage operation under the budget, with a client whose fetch it can + * abort. `run` receives the client so the operation is built INSIDE the guard — + * building it outside would bind it to the unsignalled singleton. + */ +async function withStorageDeadline( + op: string, + deadline: RouteDeadline | undefined, + run: (client: SupabaseClient) => Promise, +): Promise { + const budget = storageBudgetMs(deadline); + // Starting a call with no runway left is how a pass spends its last + // milliseconds on a request whose answer it can never use. + if (budget < STORAGE_CALL_MIN_MS) throw new StorageTimeoutError(op); + + const controller = new AbortController(); + const client = getSupabaseWithSignal(controller.signal); + if (!client) throw new Error("receipt storage is not configured"); + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + run(client), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + // Abort FIRST, so the socket goes with the promise. + controller.abort(); + reject(new StorageTimeoutError(op)); + }, budget); + }), + ]); + } finally { + // Never leave a pending timer holding the event loop open. + if (timer) clearTimeout(timer); + } +} + +/** + * The bucket policy, exported so scripts/apply-receipt-intake.mjs and this code + * cannot disagree about what was provisioned. + */ +export const RECEIPT_BUCKET_POLICY = { + name: RECEIPT_BUCKET, + public: false, + fileSizeLimit: MAX_STORED_BYTES, + allowedMimeTypes: ACCEPTED_MIME_TYPES, +} as const; + +/** A human-readable reference for logs and QBO memos. Never dereferenced. */ +export function receiptObjectRef(storagePath: string): string { + return `${RECEIPT_BUCKET}:${storagePath}`; +} + +/** A path we are willing to touch: inside the bucket, no traversal, no absolutes. */ +function safePath(storagePath: string): string | null { + if (!storagePath || storagePath.startsWith("/") || storagePath.includes("..")) return null; + return storagePath; +} + +export type SizeResult = + | { ok: true; size: number } + | { ok: false; kind: "missing" | "transient"; message?: string }; + +/** + * Byte size from METADATA — never a download. + * + * `list` with a search returns the metadata row in one small request whatever + * the object weighs, which is the only way to refuse a 400 MB upload without + * first pulling it into this process. + * + * TAGGED, and an unknown size is TRANSIENT rather than "fine, carry on". The + * previous null-means-unknown contract meant a storage hiccup, a missing + * client, or an API without metadata all fell through to the download — which + * is precisely the thing this call exists to avoid, on precisely the objects we + * know least about. + */ +export interface BucketLister { + list( + dir: string, + opts: { search: string; limit: number }, + ): Promise<{ + data: Array<{ name: string; metadata?: unknown }> | null; + error: { message?: string; status?: number; statusCode?: string | number; error?: string } | null; + }>; +} + +export async function receiptObjectSize( + storagePath: string, + /** Injected only by tests: the classification is the whole subject here. */ + lister: BucketLister | null = null, + deadline: RouteDeadline | undefined, +): Promise { + const path = safePath(storagePath); + if (!path) return { ok: false, kind: "missing" }; + const slash = path.lastIndexOf("/"); + const dir = slash > 0 ? path.slice(0, slash) : ""; + const name = slash > 0 ? path.slice(slash + 1) : path; + try { + const { data, error } = lister + ? await lister.list(dir, { search: name, limit: 100 }) + : await withStorageDeadline("list", deadline, client => + client.storage.from(RECEIPT_BUCKET).list(dir, { search: name, limit: 100 })); + if (error) { + return isNotFoundError(error as { message?: string; status?: number }) + ? { ok: false, kind: "missing" } + : { ok: false, kind: "transient", message: String(error.message ?? "list-failed").slice(0, 200) }; + } + const match = data?.find(entry => entry.name === name); + // An empty listing IS an answer: the object is not there. + if (!match) return { ok: false, kind: "missing" }; + const size = (match.metadata as { size?: unknown } | undefined)?.size; + return typeof size === "number" && Number.isFinite(size) + ? { ok: true, size } + // Present but sizeless: the one case where we genuinely do not know, + // and it must not become permission to download. + : { ok: false, kind: "transient", message: "size-unavailable" }; + } catch (error) { + return { + ok: false, + kind: "transient", + message: error instanceof Error ? `${error.name}: ${error.message}`.slice(0, 200) : "list-threw", + }; + } +} + +/** Tagged download, so a confirmed 404 and a storage blip cannot book the same. */ +export async function downloadReceiptObject( + storagePath: string, + deadline: RouteDeadline | undefined, +): Promise { + const path = safePath(storagePath); + if (!path) return { ok: false, kind: "not-found" }; + try { + const { data, error } = await withStorageDeadline("download", deadline, client => + client.storage.from(RECEIPT_BUCKET).download(path)); + if (error) { + return isNotFoundError(error as { message?: string; status?: number }) + ? { ok: false, kind: "not-found" } + : { ok: false, kind: "transient", message: String(error.message ?? "download-failed").slice(0, 200) }; + } + if (!data) return { ok: false, kind: "not-found" }; + return { ok: true, bytes: Buffer.from(await data.arrayBuffer()) }; + } catch (error) { + return { + ok: false, + kind: "transient", + message: error instanceof Error ? `${error.name}: ${error.message}`.slice(0, 200) : "download-threw", + }; + } +} + +/** Write bytes we have already validated. Returns false on any storage fault. */ +export async function uploadReceiptObject( + storagePath: string, + bytes: Buffer, + contentType: string, + opts: { upsert?: boolean; deadline: RouteDeadline | undefined }, +): Promise { + const path = safePath(storagePath); + if (!path) return false; + try { + const { error } = await withStorageDeadline("upload", opts.deadline, client => + client.storage + .from(RECEIPT_BUCKET) + .upload(path, bytes, { contentType, upsert: opts.upsert ?? false })); + if (error) { + console.error("[receipts/intake] upload failed", error.message); + return false; + } + return true; + } catch (error) { + console.error("[receipts/intake] upload threw", error instanceof Error ? error.name : "error"); + return false; + } +} + +/** Delete, and THROW on anything short of a confirmed removal. */ +export async function removeReceiptObject( + storagePath: string, + deadline: RouteDeadline | undefined, +): Promise { + const path = safePath(storagePath); + if (!path) throw new Error(`not a receipt object path: ${String(storagePath).slice(0, 80)}`); + // Never a silent success: the cleanup queue would mark an orphan resolved on + // a misconfigured deployment and lose it permanently. withStorageDeadline + // throws for a missing client and for a timeout alike, which is what this + // caller wants — both mean "not confirmed removed". + const { error } = await withStorageDeadline("remove", deadline, client => + client.storage.from(RECEIPT_BUCKET).remove([path])); + if (error) throw error; +} + +/** + * The signed URL a client PUTs its bytes to. Scoped to ONE path, by design. + * + * `upsert` IS OPT-IN, and the default is off. + * + * The option is a real capability difference, not a convenience: an + * upsert-capable token can OVERWRITE whatever is at the path for as long as it + * is valid, which outlives the row it was issued for. A token issued for a + * freshly-named path (every path is `id + leaseVersion + ext`, and every + * destructive /start branch bumps the version before it signs) can only ever + * create, so it does not need the stronger capability and must not be handed + * it. The ONE caller that does is `reuseLiveLease`: it re-signs an EXISTING + * path so a client can replace its own partial upload, and without upsert that + * second PUT fails "The resource already exists" and the row can never be + * finalized. `createSignedUploadUrl(path, { upsert })` is storage-js's own + * option (@supabase/storage-js 2.99: `createSignedUploadUrl(path, options?: { + * upsert: boolean })`), defaulting to false — the sha checks in /finalize are + * what stop even the upsert token from binding a DIFFERENT document to this + * identity. + */ +export async function createReceiptUploadUrl( + storagePath: string, + opts: { upsert?: boolean; deadline: RouteDeadline | undefined }, +): Promise<{ uploadUrl: string; token: string; storagePath: string } | null> { + const path = safePath(storagePath); + if (!path) return null; + try { + const { data, error } = await withStorageDeadline("sign-upload", opts.deadline, client => + client.storage + .from(RECEIPT_BUCKET) + .createSignedUploadUrl(path, { upsert: opts.upsert ?? false })); + if (error || !data) { + console.error("[receipts/intake] sign failed", error?.message); + return null; + } + return { uploadUrl: data.signedUrl, token: data.token, storagePath: path }; + } catch (error) { + console.error("[receipts/intake] sign threw", error instanceof Error ? error.name : "error"); + return null; + } +} + +/** A time-limited read URL, for the archive mirror. */ +export async function signReceiptDownloadUrl( + storagePath: string, + ttlSeconds: number, + deadline: RouteDeadline | undefined, +): Promise { + const path = safePath(storagePath); + if (!path) return null; + try { + const { data, error } = await withStorageDeadline("sign-download", deadline, client => + client.storage + .from(RECEIPT_BUCKET) + .createSignedUrl(path, ttlSeconds)); + return error || !data ? null : data.signedUrl; + } catch { + return null; + } +} diff --git a/src/lib/receipt-intake/cutover.ts b/src/lib/receipt-intake/cutover.ts new file mode 100644 index 000000000..1fffb0e0d --- /dev/null +++ b/src/lib/receipt-intake/cutover.ts @@ -0,0 +1,236 @@ +/** + * The v1 -> v2 cutover boundary. + * + * The problem this exists to solve: at cutover, the shadow-week backlog cannot + * all be treated the same way. Rows received while the Apps Script was still + * BOOKING were booked by v1, so v2 must never book them again — v2's QuickBooks + * identity for an email/chat/mobile/web row is the intake UUID, which v1 never + * saw, so DocNumber idempotency cannot recognise the Purchase v1 already made. + * But rows received AFTER v1 stopped booking were never booked by anyone, and + * retiring those would silently drop real expenses on the floor. + * + * One timestamp separates the two: the instant the Apps Script was flipped to + * forwarder mode and stopped writing to QuickBooks. It is recorded when that + * flip happens, NOT derived — nothing in the database can infer it, and a guess + * here either double-books or loses receipts. + * + * With no boundary recorded the worker refuses to retire anything at all. That + * is the only safe default: retiring on a guess destroys evidence, and the + * failure mode of refusing is a visible, logged no-op. + */ +import { prisma } from "@/lib/prisma"; + +export const CUTOVER_SETTING_KEY = "cutoverV1StoppedAt"; + +/** + * When v1 stopped booking. Read from the AutomationSetting row first (that is + * what an operator writes at the flip), falling back to the env var so a + * deployment can carry it too. Returns null when unset OR unparseable — a + * malformed value must not be silently treated as "epoch", which would retire + * the entire backlog. + */ +export async function resolveCutoverBoundary(): Promise { + let raw: string | null | undefined; + try { + raw = (await prisma.automationSetting.findUnique({ where: { key: CUTOVER_SETTING_KEY } }))?.value; + } catch (error) { + // A settings read failure is NOT "no boundary" — that would let a DB + // blip authorise a retire. Surface it as unset, which refuses. + console.error("[cutover] settings read failed", error instanceof Error ? error.name : "UnknownError"); + return null; + } + return parseCutoverBoundary(raw ?? process.env.CUTOVER_V1_STOPPED_AT); +} + +// Full RFC3339 date-time with a REQUIRED offset (`Z` or `±HH:MM`). `new +// Date()`/`Date.parse()` also accept date-only strings ("2026-09-01", read as +// UTC midnight) and naive local-time strings ("2026-09-01T10:00:00", read in +// the SERVER's local zone) and even ambiguous formats ("9/1/2026") — every one +// of those silently shifts the boundary by hours depending on where the +// process runs, which either retires rows v1 never booked or lets a +// v1-booked row slip through to be double-booked by v2. An explicit offset is +// the only representation that names one unambiguous instant. +const RFC3339_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/i; + +/** Pure, so the parsing rules are testable without a database. */ +export function parseCutoverBoundary(value: string | null | undefined): Date | null { + if (!value || !value.trim()) return null; + const trimmed = value.trim(); + if (!RFC3339_WITH_OFFSET.test(trimmed)) { + console.error( + "[cutover] boundary must be a full RFC3339 timestamp with an explicit Z or ±HH:MM offset " + + `(e.g. "2026-08-25T17:30:00Z") — rejecting ambiguous value: ${JSON.stringify(trimmed)}`, + ); + return null; + } + const ms = Date.parse(trimmed); + if (!Number.isFinite(ms)) return null; + return new Date(ms); +} + +/** One parked shadow row, as the cutover sees it. */ +export interface CutoverCandidate { + id: string; + source: string; + sourceRef: string; + archivedByV1: boolean; + createdAt: Date; +} + +export interface CutoverTriage { + /** v1 booked it. Retire — SHADOW_DONE, never booked here. */ + evidenced: string[]; + /** Nobody booked it (or we can collapse a double). Hand to v2. */ + unevidenced: string[]; + /** Cannot be settled from data. A human checks QuickBooks. */ + quarantined: string[]; +} + +/** The Drive file id a row books under, or null when it has no shared identity. */ +export function driveFileIdOf(row: { source: string; sourceRef: string }): string | null { + return row.source === "drive" && row.sourceRef.startsWith("drive:") + ? row.sourceRef.slice("drive:".length) + : null; +} + +/** + * Split the shadow backlog three ways. + * + * EVIDENCE OUTRANKS THE TIMESTAMP. The old rule looked only at rows older than + * the boundary, so a file v1 had already booked but the forwarder handed over + * AFTER the flip (a queued send, a retry, a slow archive step) never reached + * the evidence check at all: it went straight into the requeue and v2 booked a + * second Purchase. For an email or chat row that is unrecoverable by + * idempotency — v2 books under the intake UUID, which v1 never saw — so it is + * a real duplicate in the real books. + * + * The boundary only decides what to do with rows carrying NO evidence: + * - after it -> v1 was not booking; v2 takes it. + * - before it, Drive row -> v2 takes it. Safe because it books under the + * Drive file id, so a v1/v2 overlap collapses into one Purchase. + * - before it, anything else -> quarantine. Booking risks double-paying and + * retiring risks losing a real expense, so a human decides. + */ +export function triageCutoverRows( + candidates: CutoverCandidate[], + boundary: Date, + bookedByV1: ReadonlySet, +): CutoverTriage { + const triage: CutoverTriage = { evidenced: [], unevidenced: [], quarantined: [] }; + for (const row of candidates) { + const driveId = driveFileIdOf(row); + if (row.archivedByV1 || (driveId && bookedByV1.has(driveId))) { + triage.evidenced.push(row.id); + continue; + } + if (row.createdAt >= boundary) { + triage.unevidenced.push(row.id); + continue; + } + if (driveId) triage.unevidenced.push(row.id); + else triage.quarantined.push(row.id); + } + return triage; +} + +/** + * A candidate PLUS the row state the verdict about it was reached against. + * + * The triage above only needs the identity fields; these are the ones the write + * has to prove are still true when it lands. + */ +export interface CutoverRow extends CutoverCandidate { + state: string; + stateReason: string | null; + dryRun: boolean; + /** + * Pinned at whatever was OBSERVED, not required to be null. + * + * A shadow-parked row is excluded from the claim entirely + * (eligibleClaimWhere's NOT clause), so no live worker can be holding one + * on the pass that runs the cutover — any token still on it is a leftover + * from a pass that died during the shadow week. Demanding null would + * therefore hide such a row from the cutover FOREVER: nothing can re-claim + * it to release the token, so it would never be retired, requeued or + * quarantined, and nobody would be told. Pinning the observed value still + * catches a claim taken after the select, which is the race that matters. + */ + claimToken: string | null; +} + +export interface CutoverWriteClient { + updateMany(args: { + where: Record; + data: Record; + }): Promise<{ count: number }>; +} + +export interface CutoverMove { + /** Rows the verdict actually landed on. */ + moved: number; + /** Rows that changed under us between the select and the write. */ + skippedMoved: number; +} + +/** + * APPLY ONE CUTOVER VERDICT, FENCED ON THE ROW IT WAS DECIDED ABOUT. + * + * The three cutover writes used to constrain nothing but `id: { in: [...] }`. + * The candidates are read in the same transaction, but READ COMMITTED means a + * concurrent writer that never touches the claim's advisory lock — an admin + * review, a future queue UI, a late completion — can still move a row in the + * gap between that SELECT and these UPDATEs. The verdict then landed on a row + * it was never computed for: a human's review was overwritten with + * SHADOW_DONE or SHADOW_QUARANTINE, or `dryRun: false` handed a row to v2 that + * somebody had just parked. Every one of those is terminal and none of them is + * visible afterwards. + * + * So each write re-asserts the WHOLE predicate the row was selected by + * (`dryRun: true`, one of the parked states) plus the exact evidence the + * verdict was reached on: that row's own `state`, `stateReason` and + * `claimToken`. A row that moved matches nothing, is counted as + * `skippedMoved`, and simply comes back round on the next pass — where it will + * be triaged against whatever it looks like then. + * + * Grouped by the observed (state, stateReason, claimToken) rather than issued + * per row: the shadow backlog is the whole of a week and this runs inside the claim + * transaction, so one statement per distinct observed state is the difference + * between a handful of round trips and hundreds. + */ +export async function applyCutoverVerdict( + rows: CutoverRow[], + data: Record, + db: CutoverWriteClient, +): Promise { + type Group = { state: string; stateReason: string | null; claimToken: string | null; ids: string[] }; + const groups = new Map(); + for (const row of rows) { + // JSON, so two different observations can never collapse into one + // group and be written under each other's fence. + const key = JSON.stringify([row.state, row.stateReason, row.claimToken]); + const group = groups.get(key) + ?? { state: row.state, stateReason: row.stateReason, claimToken: row.claimToken, ids: [] }; + group.ids.push(row.id); + groups.set(key, group); + } + + let moved = 0; + let skippedMoved = 0; + for (const group of groups.values()) { + const { count } = await db.updateMany({ + where: { + id: { in: group.ids }, + // The parked predicate the candidates were SELECTED by... + dryRun: true, + // ...and the exact evidence this verdict was reached on. + state: group.state, + stateReason: group.stateReason, + claimToken: group.claimToken, + }, + data, + }); + moved += count; + skippedMoved += group.ids.length - count; + } + return { moved, skippedMoved }; +} diff --git a/src/lib/receipt-intake/file-type.ts b/src/lib/receipt-intake/file-type.ts new file mode 100644 index 000000000..097d51f27 --- /dev/null +++ b/src/lib/receipt-intake/file-type.ts @@ -0,0 +1,76 @@ +/** + * What the intake endpoint is willing to store, decided on the BYTES. + * + * The client-claimed mime is attacker-controlled, so images are identified by + * their magic bytes the way src/app/api/receipts/parse/route.ts:37 does. + * PDF and HEIC have signatures too and are checked here; text/plain has none, + * so it is the only type allowed to arrive on its declared word. + * + * Lives in lib/, not in the route: a Next route file may only export the + * framework's own names, and this is unit-tested on its own. + */ + +export const EXT_BY_MIME: Record = { + "application/pdf": "pdf", + "image/jpeg": "jpg", + "image/png": "png", + "image/heic": "heic", + "image/heif": "heic", + "image/webp": "webp", + "image/gif": "gif", + // text/plain is deliberately absent: QuickBooks cannot attach a .txt, so a + // row created for one would read fine and then strand unbookable. Both + // intake paths refuse it — see the 415 in the route and /start. +}; + +/** The formats a caller may be told to send. Single source for the 415 body. */ +export const ACCEPTED_MIME_TYPES = Object.keys(EXT_BY_MIME); + +/** @deprecated Unused — the one ceiling is MAX_STORED_BYTES in intake-core.ts. */ +export const MAX_INTAKE_BYTES = 8 * 1024 * 1024; + +/** ISO-BMFF major brands stored as image/heic (still + HEVC sequence brands). */ +export const HEIC_BRANDS = new Set(["heic", "heix", "hevc", "hevx", "msf1"]); +/** The generic HEIF brands — stored under their own content type. */ +export const HEIF_BRANDS = new Set(["mif1", "heif"]); + +/** Returns the accepted mime, or null when the bytes are not a supported document. */ +export function sniffMime(buf: Buffer, declared: string): string | null { + const essence = declared.split(";")[0].trim().toLowerCase(); + if (buf.length === 0) return null; + if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg"; + if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png"; + if (buf.length >= 4 && buf.subarray(0, 4).toString("ascii") === "GIF8") return "image/gif"; + if ( + buf.length >= 12 && + buf.subarray(0, 4).toString("ascii") === "RIFF" && + buf.subarray(8, 12).toString("ascii") === "WEBP" + ) return "image/webp"; + if (buf.length >= 5 && buf.subarray(0, 5).toString("ascii") === "%PDF-") return "application/pdf"; + if (buf.length >= 12 && buf.subarray(4, 8).toString("ascii") === "ftyp") { + // ISO-BMFF major brands, per ISO/IEC 23008-12. iPhones emit `heic` + // (still) and `heix`; a burst or a Live Photo still can carry the HEVC + // brands `hevc`/`hevx`, which an earlier `hei` prefix check silently + // refused — those uploads came back "unsupported-file-type" from a + // perfectly readable photo. The image-SEQUENCE brands (`hevc`, `hevx`, + // `msf1`) are grouped with HEIC because Gemini and QBO both accept them + // under that content type. + const brand = buf.subarray(8, 12).toString("ascii").toLowerCase(); + if (HEIC_BRANDS.has(brand)) return "image/heic"; + // `mif1`/`heif` are the generic HEIF brands — kept as image/heif so the + // stored mimeType says what the file actually claims to be. + if (HEIF_BRANDS.has(brand)) return "image/heif"; + } + // text/plain is DELIBERATELY not accepted. + // + // QuickBooks cannot attach a .txt, so such a row read fine and then parked + // at booking with unsupported-attachment — stuck mid-pipeline, which is + // worse than a clear refusal at the door. v1 converted these to PDF using + // Apps Script's HTML->PDF `getAs`, which has no Node equivalent: a real + // port means a PDF generator with wrapping, pagination and WinAnsi encoding + // (pdf-lib's standard fonts THROW on characters they cannot encode). That + // is a new silent-corruption surface on a money document, for the rarest + // input in the pipeline. Refused instead — see the 415 in the intake route. + void essence; + return null; +} diff --git a/src/lib/receipt-intake/intake-auth.ts b/src/lib/receipt-intake/intake-auth.ts new file mode 100644 index 000000000..b84495713 --- /dev/null +++ b/src/lib/receipt-intake/intake-auth.ts @@ -0,0 +1,144 @@ +/** + * Auth for /api/receipts/intake and its sub-routes. + * + * Every one of these paths is on the proxy's EXACT-MATCH public bypass + * (src/proxy.ts) so machine callers get a clean 401 instead of a 307 to + * /login. That makes this the ONLY gate, so it fails closed everywhere: + * + * - no secret configured -> that capability is refused outright, never + * "allow because unset" (getclients-auth-gate lesson). + * - a bogus/expired session cookie -> authenticateMobileOrSession returns + * ok:false, and this returns 401 JSON, never a redirect. + * + * TWO SECRETS, NOT ONE. They belong to different programs with different + * blast radii: + * + * RECEIPT_INTAKE_SECRET — the forwarders. May only INGEST, and only under + * the sources they actually own (drive/email/chat). Cannot read the queue, + * cannot see another job's receipts, cannot archive anything. + * RECEIPT_ARCHIVE_SECRET — the nightly Drive mirror. May only READ + * BOOKED/ARCHIVED rows and report back what it archived. Cannot create a + * row, cannot publish one, cannot touch a document's contents. + * + * One shared secret gave a script that only copies files to Drive the power to + * inject Purchases into the books, and gave the ingest forwarders the power to + * enumerate every receipt in the system. Splitting them means a leak of either + * one is bounded by what that program actually does. They rotate independently + * for the same reason. + */ +import { createHash, timingSafeEqual } from "node:crypto"; +import { NextResponse } from "next/server"; +import { authenticateMobileOrSession } from "@/lib/mobile-auth"; +import type { User } from "@prisma/client"; + +export const RECEIPT_INTAKE_SECRET_HEADER = "x-receipt-intake-secret"; + +/** What a caller is asking to do. Checked against the secret it presented. */ +export type IntakeCapability = "ingest" | "archive"; + +export type IntakeAuth = + | { + ok: true; + via: "secret"; + user: null; + userVia: null; + capability: IntakeCapability; + /** Sources this secret may declare. Empty for the archive secret. */ + allowedSources: ReadonlySet; + } + /** `userVia` distinguishes the crew app from a browser — the route mints `source` from it. */ + | { ok: true; via: "session"; user: User; userVia: "mobile-jwt" | "next-auth" } + | { ok: false; response: NextResponse }; + +/** The forwarders own these three and nothing else. */ +export const INGEST_ALLOWED_SOURCES: ReadonlySet = new Set(["drive", "email", "chat"]); + +/** Constant-time compare over fixed-length digests, so header length leaks nothing. */ +export function secretMatches(provided: string | null, expected: string | undefined): boolean { + if (!expected) return false; + const expectedDigest = createHash("sha256").update(expected).digest(); + const gotDigest = createHash("sha256").update(provided ?? "").digest(); + return timingSafeEqual(expectedDigest, gotDigest); +} + +function unauthorized(): NextResponse { + return NextResponse.json({ ok: false, reason: "unauthorized" }, { status: 401 }); +} + +function wrongCapability(have: IntakeCapability, need: IntakeCapability): NextResponse { + // 403, not 401: the caller IS authenticated, it just holds the other + // program's key. Saying so is what makes a mis-wired script obvious + // instead of looking like a rotation problem. + return NextResponse.json( + { ok: false, reason: "forbidden", have, need }, + { status: 403 }, + ); +} + +/** + * Secret first, then a session/mobile-Bearer user. + * + * `need` is what the ROUTE requires. A caller presenting a valid secret for the + * OTHER capability is refused with 403 rather than falling through to the + * session check — a forwarder must never be able to read the queue by holding + * the ingest key, and the mirror must never be able to create a row. + */ +export async function authenticateIntake( + req: Request, + need: IntakeCapability = "ingest", +): Promise { + const provided = req.headers.get(RECEIPT_INTAKE_SECRET_HEADER); + if (provided !== null) { + const ingest = process.env.RECEIPT_INTAKE_SECRET; + const archive = process.env.RECEIPT_ARCHIVE_SECRET; + + // THE TWO-SECRET INVARIANT MUST HOLD REGARDLESS OF WHAT WAS PRESENTED. + // + // Both secrets configured, non-empty, and distinct — checked BEFORE any + // compare against `provided`. Checking it only by way of "both matched + // the same value" (the old shape) missed the far more likely + // misconfiguration: just ONE var set (a rotation half-done, a preview + // env missing a copy-paste). With only RECEIPT_INTAKE_SECRET set, + // `secretMatches(provided, archive)` is false for every input — not + // because the caller lacks the archive key, but because there IS no + // archive key — so a caller holding the ingest secret sailed through + // untouched while the archive program was silently unreachable by + // anyone AND the ingest key was one env-var away from also being + // accepted as the archive key the moment someone filled it in wrong. + if (!ingest || !archive || ingest === archive) { + console.error("[receipts/intake] RECEIPT_INTAKE_SECRET / RECEIPT_ARCHIVE_SECRET misconfigured (missing or identical) — refusing every secret-authenticated request"); + return { ok: false, response: unauthorized() }; + } + + // Both compares always run: short-circuiting on the first match would + // make the response time depend on WHICH key was presented. + const isIngest = secretMatches(provided, ingest); + const isArchive = secretMatches(provided, archive); + + if (!isIngest && !isArchive) return { ok: false, response: unauthorized() }; + + const capability: IntakeCapability = isIngest ? "ingest" : "archive"; + if (capability !== need) return { ok: false, response: wrongCapability(capability, need) }; + + return { + ok: true, + via: "secret", + user: null, + userVia: null, + capability, + allowedSources: capability === "ingest" ? INGEST_ALLOWED_SOURCES : new Set(), + }; + } + + const auth = await authenticateMobileOrSession(req); + if (!auth.ok) { + // Preserve 403 for a DISABLED account; everything else is 401 JSON. + return { + ok: false, + response: NextResponse.json({ ok: false, reason: "unauthorized" }, { status: auth.status }), + }; + } + return { ok: true, via: "session", user: auth.user, userVia: auth.via }; +} + +export const STAFF_READ_ROLES = ["ADMIN", "MANAGER", "FINANCE"]; diff --git a/src/lib/receipt-intake/intake-core.ts b/src/lib/receipt-intake/intake-core.ts new file mode 100644 index 000000000..fb7250a36 --- /dev/null +++ b/src/lib/receipt-intake/intake-core.ts @@ -0,0 +1,223 @@ +/** + * Shared intake rules, so the single-shot POST and the two-step + * start/finalize flow cannot drift apart on provenance, idempotency or limits. + */ +import type { IntakeAuth } from "./intake-auth"; + +/** + * The single-shot POST carries the file in the REQUEST BODY, and a serverless + * request body is not a 15 MB pipe: Vercel caps it at 4.5 MB and the base64 + * JSON shape inflates the payload by a third on top of that. Anything larger + * was failing at the platform edge with an opaque 413 that never reached this + * code — so the endpoint now says so itself, and points at the two-step flow + * that uploads straight to storage and has no body limit at all. + */ +export const MAX_INLINE_UPLOAD_BYTES = 4 * 1024 * 1024; + +/** + * The JSON path's raw-bytes ceiling, LOWER than the multipart one on purpose. + * + * A JSON body carries the file base64-encoded, which inflates it by 4/3. At the + * multipart limit of 4 MiB that is a ~5.4 MiB request — over the platform's + * body cap, so it died at the edge with an opaque 413 this code never saw and + * the caller learned nothing. 3 MiB raw encodes to ~4 MiB, which fits. + * + * Multipart sends the bytes as-is and keeps the full 4 MiB. + */ +export const MAX_INLINE_JSON_BYTES = 3 * 1024 * 1024; + +/** + * QuickBooks refuses an attachment over 8 MiB, and a receipt that cannot be + * attached is worse than one that was never accepted: the Purchase is created, + * the file is not on it, and the books look complete. THIS is therefore the + * ceiling for the whole pipeline, not just for the booking step. + * + * It used to be 15 MiB at the door and 8 MiB at the books, and everything in + * between was accepted, stored, read by the model, and then parked + * `unsupported-attachment:size` — after a human had already been told we had + * it. One number, enforced at every layer that can enforce anything: + * + * * the bucket's own file_size_limit (the only place a signed-URL write can + * be refused at all — see bucket.ts / apply-receipt-intake.mjs), + * * /start, on the size the client declares, + * * inspectStoredObject, on the object's metadata and then on its bytes, + * * attachmentBlocker, as the last preflight before the Purchase. + */ +export const QBO_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; + +/** The real ceiling for a stored receipt, enforced on the object itself. */ +export const MAX_STORED_BYTES = QBO_ATTACHMENT_MAX_BYTES; + +/** + * HOW SURE THE MODEL MUST BE BEFORE ITS PHASE SUGGESTION IS APPLIED. + * + * The reader's prompt (read.ts, STEP 3) asks for a 0..1 confidence and tells the + * model plainly that "a low number sends the receipt to a human". Nothing ever + * read the number: `suggestedCostCodeId` was applied to the Expense whatever the + * confidence said, including when the model itself reported it was guessing. So + * a phase the document never pointed at rode into the books, and every job-cost + * and variance report counted it as spend on a line nobody budgeted — silently, + * because the audit note read "phase suggested" either way. + * + * 0.6 because the suggestion is cheap to withhold and expensive to get wrong: a + * withheld phase leaves the Expense UNCODED and visible in the bookkeeper's + * queue, where coding it is one click; an applied wrong phase is invisible and + * has to be found in a variance report weeks later. Env-overridable so the + * threshold can be tuned from what the queue actually shows without a deploy. + * + * A HUMAN'S EXPLICIT PICK IS NEVER SUBJECT TO THIS — it is not a suggestion. + */ +export const RECEIPT_PHASE_CONFIDENCE_MIN = 0.6; + +/** The effective threshold. A junk or out-of-range override is ignored, not obeyed. */ +export function phaseConfidenceMin( + raw: string | undefined = process.env.RECEIPT_PHASE_CONFIDENCE_MIN, +): number { + if (raw === undefined || raw.trim() === "") return RECEIPT_PHASE_CONFIDENCE_MIN; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 && value <= 1 ? value : RECEIPT_PHASE_CONFIDENCE_MIN; +} + +/** + * NULL IS NOT ZERO AND IT IS NOT "SURE". + * + * `normalizeConfidence` returns null when the model gave no number at all (an + * older prompt, a truncated response, a phase list that was never sent). That is + * an ABSENT answer, and an absent answer cannot clear a threshold — treating it + * as passing would apply exactly the suggestions we know least about. + */ +export function phaseSuggestionIsConfident( + confidence: number | null | undefined, + min: number = phaseConfidenceMin(), +): boolean { + return typeof confidence === "number" && Number.isFinite(confidence) && confidence >= min; +} + +/** Sources a shared-secret forwarder may declare. */ +export const MACHINE_SOURCES = new Set(["drive", "email", "chat"]); +/** Minted server-side from the authenticated caller, never read off the body. */ +export const USER_SOURCES = new Set(["mobile", "web"]); + +/** Client-supplied idempotency tokens must be real UUIDs — never a free-text key. */ +export const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type SourceDecision = + | { ok: true; source: string; sourceRef: string } + | { ok: false; reason: string }; + +/** + * Decide `source` and `sourceRef` from the AUTH KIND, not the body. + * + * A session or Bearer caller knows neither: letting one pass `source:"drive"` + * plus a chosen `sourceRef` would let it claim another document's idempotency + * key, and `drive` rows book under the Drive file id — which is what a QBO + * DocNumber is derived from. A forwarder owns both, but only inside its own + * namespace. + */ +export function decideSource( + auth: Extract, + body: { + source?: string | null; + sourceRef?: string | null; + uploadId?: string | null; + /** sha256 of the bytes being (or about to be) uploaded, hex, any case. */ + checksum?: string | null; + }, +): SourceDecision { + if (auth.via === "secret") { + const source = String(body.source ?? ""); + // The SECRET's own list, not a global one: a key is scoped to the + // sources its program actually owns. + if (!auth.allowedSources.has(source)) return { ok: false, reason: "invalid-source" }; + if (!MACHINE_SOURCES.has(source)) return { ok: false, reason: "invalid-source" }; + if (!body.sourceRef) return { ok: false, reason: "missing-sourceRef" }; + // SHAPE, not just namespace. `drive:` with an empty tail used to be a + // valid permanent idempotency key that every later empty-tail forward + // collided with — and for Drive the tail is also the QuickBooks + // DocNumber seed. + const shape = validateSourceRef(source, body.sourceRef); + if (!shape.ok) return { ok: false, reason: shape.reason }; + return { ok: true, source, sourceRef: body.sourceRef }; + } + + const source = auth.userVia === "mobile-jwt" ? "mobile" : "web"; + if (body.source && body.source !== source) return { ok: false, reason: "invalid-source" }; + // A RAW sourceRef stays forbidden — provenance is not caller input. + if (body.sourceRef) return { ok: false, reason: "sourceRef-not-allowed" }; + + // `uploadId` is the client's own idempotency token, SCOPED TO THE USER + // server-side: two people cannot collide on one uuid, and nobody can reach + // another user's row by guessing one. Without it a phone that retries on a + // flaky connection books the same receipt twice. + if (body.uploadId) { + if (!UUID_PATTERN.test(body.uploadId)) return { ok: false, reason: "invalid-uploadId" }; + return { ok: true, source, sourceRef: `${source}:${auth.user.id}:${body.uploadId.toLowerCase()}` }; + } + + // No client-chosen token: fall back to a STABLE key derived from the + // bytes themselves, scoped to the user. Minting a random uuid here meant + // every retry with no uploadId — a flaky connection, a double-tap on a + // slow spinner — was accepted as a brand new receipt, because nothing + // about the row's identity depended on what was actually uploaded. + const checksum = (body.checksum ?? "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(checksum)) return { ok: false, reason: "missing-idempotency-key" }; + return { ok: true, source, sourceRef: `session:${auth.user.id}:${checksum}` }; +} + +/** + * The longest sourceRef we will store. Long enough for any real Chat resource + * name, short enough that it cannot be used as a payload: it lands in a UNIQUE + * index, in QuickBooks-facing identity (Drive rows book under this id) and in + * every log line about the row. + */ +export const MAX_SOURCE_REF_BYTES = 512; + +/** + * Per-source shape for the part AFTER `:`. + * + * The namespace prefix alone was the only check, so `drive:` with nothing after + * it was a valid, unique, permanent idempotency key — and every subsequent + * empty-tail forward collided with it and was answered "already received", + * silently dropping real receipts. Worse for Drive specifically: the tail IS + * the QuickBooks DocNumber seed, so a junk tail becomes a junk DocNumber and + * two junk tails sharing a 21-character prefix collide in the books. + * + * The shapes are the ones the Apps Script forwarder actually sends (see the + * `sourceRef` doc on the Prisma model), NOT a superset invented here — a + * validator that accepts more than production sends is a validator that would + * have accepted the bug it exists to stop: + * drive — `drive:`: the Drive file id, which is also the QuickBooks + * DocNumber seed for these rows. + * email — `email::`: one message can carry several + * receipts, so the message id alone is not an identity; the 16-hex + * content hash distinguishes them. + * chat — `chat::`: a Chat message resource name + * (`spaces//messages/`) plus the attachment index. + */ +export const SOURCE_REF_PATTERNS: Record = { + drive: /^[A-Za-z0-9_-]{10,128}$/, + email: /^[A-Za-z0-9_.+=~-]{1,256}:[0-9a-f]{16}$/, + chat: /^spaces\/[A-Za-z0-9_-]{1,128}\/messages\/[A-Za-z0-9_.=-]{1,256}:\d{1,4}$/, +}; + +export type SourceRefCheck = { ok: true } | { ok: false; reason: string }; + +/** Shared by the single-shot POST and /start — one shape rule, checked once. */ +export function validateSourceRef(source: string, sourceRef: string): SourceRefCheck { + if (Buffer.byteLength(sourceRef, "utf8") > MAX_SOURCE_REF_BYTES) { + return { ok: false, reason: "sourceRef-too-long" }; + } + const prefix = `${source}:`; + if (!sourceRef.startsWith(prefix)) return { ok: false, reason: "sourceRef-namespace-mismatch" }; + const tail = sourceRef.slice(prefix.length); + if (!tail) return { ok: false, reason: "invalid-sourceRef" }; + // No control characters or whitespace anywhere, whatever the source: this + // value is echoed into logs and compared for equality. + if (/[\u0000-\u001f\u007f\s]/.test(sourceRef)) return { ok: false, reason: "invalid-sourceRef" }; + const pattern = SOURCE_REF_PATTERNS[source]; + // An unknown source never reaches here (decideSource checks the list first), + // and if one ever did, "no pattern" must not mean "anything goes". + if (!pattern) return { ok: false, reason: "invalid-source" }; + return pattern.test(tail) ? { ok: true } : { ok: false, reason: "invalid-sourceRef" }; +} diff --git a/src/lib/receipt-intake/keys.ts b/src/lib/receipt-intake/keys.ts new file mode 100644 index 000000000..3c157fdf8 --- /dev/null +++ b/src/lib/receipt-intake/keys.ts @@ -0,0 +1,175 @@ +/** + * Dedup keys — a VERBATIM port of the v3.6 Apps Script logic in + * qbo-clasp/runReceiptAutomation.js. Line references below point at that file. + * + * These functions decide whether two documents are the same purchase. During + * the shadow week v1 (Apps Script) and v2 (this) must agree on every archived + * file, so the rules are ported as-is rather than "improved" — a cleaner rule + * that disagrees is a regression, not a fix. + * + * Pure: no I/O, no clock, no database. Everything here is unit-tested against + * real August archive filenames (tests/receipt-intake-keys.test.ts). + */ + +/** :1478 — strip punctuation, collapse whitespace to underscores. */ +export function sanitize(str: unknown): string { + if (!str) return ""; + return String(str).replace(/[^\w\s\-.]/gi, "").replace(/\s+/g, "_").trim(); +} + +/** :1494 — pull a clean YYYY-MM-DD out of the AI's date string (accepts an ISO timestamp). */ +export function normalizeDateStr(s: unknown): string { + const m = String(s ?? "").trim().match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : ""; +} + +/** :1500 — real calendar-date check (rejects 2026-13-05, 2026-02-30). */ +export function isValidDate(s: unknown): boolean { + const value = String(s ?? ""); + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const p = value.split("-"); + const y = parseInt(p[0], 10), m = parseInt(p[1], 10), d = parseInt(p[2], 10); + const dt = new Date(y, m - 1, d); + return dt.getFullYear() === y && dt.getMonth() === m - 1 && dt.getDate() === d; +} + +/** :1519 — handles "$1,234.56", "-12.50" and accounting negatives "(123.45)". */ +export function cleanMoney(v: unknown): string { + let s = String(v === undefined || v === null ? "" : v).trim(); + const paren = /^\(.*\)$/.test(s); + s = s.replace(/[^0-9.\-]/g, ""); + let n = parseFloat(s); + if (isNaN(n) || !isFinite(n)) return "0.00"; + if (paren && n > 0) n = -n; + return n.toFixed(2); +} + +/** + * :1578 — values that are the AI's way of saying "I couldn't find a number". + * "INV"/"ORDER"/"REF" are deliberately NOT here: they legitimately prefix real + * numbers ("INV-95870" must stay authoritative). + */ +export const REF_PLACEHOLDERS = [ + "na", "none", "null", "nil", "no", "noinv", "noinvoice", "nonum", + "unknown", "unk", "blank", "notavailable", "nodata", "notfound", + "tbd", "missing", "pending", "illegible", "unreadable", +]; + +/** + * :1581 — does this look like a real invoice/check number? Load-bearing since + * v3.6: the vendor no longer separates the namespaces, so a placeholder would + * make "2026-07-21|na" the SHARED key of every unrelated receipt that day and + * silently quarantine real expenses against each other. Rejecting here is only + * a DOWNGRADE, never a loss — the document still goes through the weak net, + * which asks a human instead of deciding on its own. + */ +export function refLooksReal(ref: unknown): boolean { + const r = String(ref ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + if (r.length < 3) return false; // too short to identify anything + const digits = r.replace(/[^0-9]/g, ""); + if (!digits) return false; // every real invoice/check # has digits + const letters = r.replace(/[^a-z]/g, ""); + if (letters && REF_PLACEHOLDERS.indexOf(letters) > -1) return false; + return !/^(.)\1*$/.test(digits); // "0000" identifies nothing +} + +/** + * :1609 — one vendor -> one token. A substring hit wins, so an unlucky entry + * can over-collapse two real vendors ("Palace Hardware" would match + * "acehardware"). Deliberately tolerable: this token feeds ONLY the weak key, + * whose worst outcome is asking a human — never a silent quarantine. + */ +export const VENDOR_ALIASES = [ + "lowes", "homedepot", "amazon", "costco", "walmart", "safeway", "fredmeyer", + "officedepot", "acehardware", "harborfreight", "sherwinwilliams", "dutch", + "usmarket", "spaceage", "irongate", "sunbelt", "valvoline", "rtastore", + "unitedbuilding", "parrlumber", "lesschwab", "jiffylube", +]; + +/** :1615 */ +export function canonicalVendor(vendor: unknown): string { + const v = String(vendor ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + for (let i = 0; i < VENDOR_ALIASES.length; i++) { + if (v.indexOf(VENDOR_ALIASES[i]) > -1) return VENDOR_ALIASES[i]; + } + return v; +} + +/** + * :1558 — date|invoice(or check#). The VENDOR and the AMOUNT stay OUT of this + * key on purpose (rationale at :1545–1557): the AI reads a chain's name + * differently across that chain's own formats, and a misread total must still + * let both copies collapse onto one key. + */ +export function makeStrongDedupKey(date: string, ref: string): string { + return [String(date ?? ""), String(ref ?? "").toLowerCase()].join("|"); +} + +/** :1598 — second net: canonical vendor|date|amount. Built for EVERY document. */ +export function makeWeakDedupKey(vendor: unknown, date: string, amount: string): string { + return [canonicalVendor(vendor), String(date ?? ""), String(amount ?? ""), "amt"].join("|"); +} + +/** What dedupKeys() needs off a read document. Mirrors the Apps Script's cleaned locals. */ +export interface DedupKeyInput { + /** "check" routes the ref through checkNumber; anything else uses `invoice`. */ + docType?: string | null; + vendor?: string | null; + /** The date AS READ off the document — NOT the fallback. */ + date?: string | null; + invoice?: string | null; + checkNumber?: string | null; + /** Raw total from the reader; cleaned here with cleanMoney. */ + totalAmount?: string | number | null; + /** + * Date to use when the document's own date is unreadable — the intake + * row's createdAt date. v1 used the Drive UPLOAD date (:1509); same + * semantic, since the intake row is created when the file arrives. + */ + fallbackDateStr: string; +} + +export interface DedupKeys { + /** Non-null ONLY when the date came off the document AND the ref passes refLooksReal. */ + strong: string | null; + /** Always built. */ + weak: string; + /** The date actually used (document date, else fallback) — what the row stores. */ + dateStr: string; + /** The cleaned ref: "Check" for checks, the cleaned invoice otherwise. */ + ref: string; + /** cleanMoney output, 2dp. */ + amount: string; +} + +/** + * Port of processSingleFile steps 2 and 4 (:512–:530, :559+): clean the read, + * then build both keys. + * + * The strong key is withheld (null) unless BOTH halves were read off the + * document. That is the v3.6 rule and it is the reason a placeholder ref can + * never quarantine unrelated receipts against each other. + */ +export function dedupKeys(input: DedupKeyInput): DedupKeys { + const isCheck = String(input.docType ?? "receipt").toLowerCase() === "check"; + + const aiDate = normalizeDateStr(input.date); + const dateReadOffDocument = isValidDate(aiDate); + const dateStr = dateReadOffDocument ? aiDate : input.fallbackDateStr; + + const checkNum = sanitize(input.checkNumber) || "NoNum"; + const ref = isCheck ? `Check${checkNum}` : (sanitize(input.invoice) || "NoInv"); + const amount = cleanMoney(input.totalAmount); + + const strong = dateReadOffDocument && refLooksReal(ref) + ? makeStrongDedupKey(dateStr, ref) + : null; + + return { + strong, + weak: makeWeakDedupKey(input.vendor, dateStr, amount), + dateStr, + ref, + amount, + }; +} diff --git a/src/lib/receipt-intake/late-fields.ts b/src/lib/receipt-intake/late-fields.ts new file mode 100644 index 000000000..202eed5d9 --- /dev/null +++ b/src/lib/receipt-intake/late-fields.ts @@ -0,0 +1,298 @@ +/** + * Late job/phase assignment on an intake row. + * + * A forwarder often knows the bytes before it knows the job: Drive files land + * in a project folder, mobile captures pick a job on the next screen, and a + * replayed finalize carries the fields the first attempt could not. So the + * fields may arrive AFTER the row does — but only under rules, because every + * one of them is a way to change money after the fact. + * + * Extracted from the route so the races are testable: the whole point of this + * module is what happens when a worker, a second caller, or a state transition + * moves the row between the read and the write. + */ + +export interface LateFields { + costCodeId?: string; + projectId?: string; +} + +export interface LateFieldRow { + costCodeId: string | null; + projectId: string | null; + state: string; + claimToken?: string | null; +} + +/** A refusal, shaped so the route can hand it straight to NextResponse.json. */ +export interface Denial { + status: number; + body: Record; +} + +export interface LateFieldsDeps { + read(id: string): Promise; + /** updateMany fenced on {id, state, claimToken: null, : null}; returns the count. */ + applyIfNull(id: string, state: string, toApply: Record): Promise; + /** Re-runs the caller's authorization against a given project. */ + authorize(projectId: string | null): Promise; +} + +/** Late fields may only land while a row is still un-routed. */ +export const LATE_FIELD_STATES = ["STAGING", "RECEIVED"]; + +export async function reconcileLateFields( + id: string, + lateFields: LateFields, + deps: LateFieldsDeps, +): Promise { + const entries = Object.entries(lateFields).filter(([, value]) => value !== undefined) as Array< + ["costCodeId" | "projectId", string] + >; + if (entries.length === 0) return null; + + const current = await deps.read(id); + if (!current) return null; + + // NULL-OR-EQUAL only, and only BEFORE the row is routed. + // + // Past RECEIVED the read has already happened: the dedup keys, the phase + // suggestion and possibly a booking were all computed from the project this + // row had at the time. Changing it afterwards does not re-derive any of + // that — it just makes the row disagree with its own history, and after + // BOOKED it disagrees with a Purchase in the real books. + if (!LATE_FIELD_STATES.includes(current.state)) { + const differs = entries.some(([key, value]) => current[key] !== value); + if (!differs) return null; // already exactly what the caller is asking for + return { + status: 409, + body: { + ok: false, + error: "late-fields-too-late", + reason: `this row is ${current.state}; its job and phase were already used to route it`, + state: current.state, + }, + }; + } + + const conflicts = entries.filter(([key, value]) => current[key] !== null && current[key] !== value); + if (conflicts.length > 0) { + return { + status: 409, + body: { + ok: false, + error: "late-fields-conflict", + reason: "this row already carries different values for these fields", + fields: Object.fromEntries( + conflicts.map(([key]) => [key, { stored: current[key], supplied: lateFields[key] }]), + ), + }, + }; + } + + const toApply = Object.fromEntries(entries.filter(([key]) => current[key] === null)); + if (Object.keys(toApply).length === 0) return null; + + // THE ROW MUST BE UNCLAIMED. + // + // A worker that claimed this row read its projectId at claim time and is + // routing on that value right now. Writing a project underneath it does not + // change what it decided — it just makes the row disagree with the routing + // it is about to publish (a receipt that now HAS a job, parked NEEDS_JOB). + // The fence is applied by the caller's `applyIfNull`. + const count = await deps.applyIfNull(id, current.state, toApply as Record); + if (count > 0) return null; + + // The CAS lost. That is NOT automatically "busy": the same zero comes back + // when a concurrent caller already wrote exactly these values, when the + // state moved, and when a DIFFERENT project was written underneath us. + // Re-read and decide from what is persisted, not from the stale read. + const after = await deps.read(id); + const settled = after !== null && entries.every(([key, value]) => after[key] === value); + if (!settled) { + return { + status: 409, + body: { + ok: false, + error: "late-fields-busy", + reason: "this row changed underneath the write; retry in a moment", + retryable: after?.claimToken != null, + state: after?.state ?? "gone", + }, + }; + } + + // The values match — but a concurrent write may have moved the PROJECT, and + // the phase we were asked to accept was authorized against the project this + // row had BEFORE that. Re-authorize against the project the row carries + // now; otherwise losing a race is a way to attach a cost code from another + // job, which is exactly what the first authorization existed to prevent. + return await deps.authorize(after.projectId); +} + +/** + * A phase is only valid against the job it belongs to. + * + * Shared by /start and /finalize deliberately. /start used to store a + * caller-supplied `costCodeId` unchecked, and nothing downstream re-checked it: + * /finalize only authorizes the fields the finalize CALL carries, so omitting + * the field there let a cross-project phase survive all the way into the + * Expense — where every variance report reads it as overspend on a line nobody + * budgeted, on a job that never bought it. + */ +export async function authorizePhase( + projectId: string | null, + costCodeId: string | null, + isCostCodeAllowed: (projectId: string, costCodeId: string) => Promise, +): Promise { + if (!costCodeId) return null; + if (!projectId) { + return { + status: 400, + body: { + ok: false, + error: "cost-code-without-project", + reason: "a phase is only meaningful against a job", + }, + }; + } + if (!(await isCostCodeAllowed(projectId, costCodeId))) { + return { + status: 400, + body: { + ok: false, + error: "cost-code-not-a-phase", + reason: "that cost code is not a phase of this job", + projectId, + }, + }; + } + return null; +} + +/** + * Fields CAPTURED when the row was created, which a later finalize may fill in + * but never overwrite. + * + * `installedAtCustomer` is listed deliberately even though the column does not + * exist yet (it lands in Phase 3): the rule is written over whatever keys the + * caller hands in, so the field is covered the day it is added rather than + * needing somebody to remember this file. It is a TAX answer — whether the + * material was installed at the customer's site decides how the purchase is + * taxed — so an overwrite there is a wrong number in the books, not a mislabel. + */ +export const CAPTURED_FIELDS = ["projectId", "costCodeId", "installedAtCustomer"] as const; + +export type CapturedValues = Record; + +export interface CapturedMerge { + /** Only the fields that are actually changing. */ + apply: Record; + /** + * The ORIGINAL captured values, for a CAS on the publishing update: if any + * of them moved between the read and the write, the publish must lose. + */ + guard: Record; + /** What the row will hold once `apply` lands. */ + resulting: { projectId: string | null; costCodeId: string | null }; + /** Where each half of the resulting phase tuple came from. */ + from: { projectId: "captured" | "late" | "none"; costCodeId: "captured" | "late" | "none" }; +} + +/** + * NULL-OR-EQUAL at publish time too. + * + * Initial publication used to spread the finalize's late fields straight over + * the row, which made /finalize the one path that could silently REPLACE a job, + * phase or tax answer captured at /start — the exact overwrite every other path + * refuses. A client that captured the job at /start and sent a different one at + * finalize simply won, and nothing recorded that the first answer ever existed. + */ +export function mergeCapturedFields( + captured: CapturedValues, + lateFields: LateFields, +): Denial | CapturedMerge { + const apply: Record = {}; + const guard: Record = {}; + const conflicts: Record = {}; + + for (const [key, value] of Object.entries(captured)) { + // The CAS covers EVERY captured field, not just the ones being written: + // a concurrent writer that filled in the phase we are about to leave + // alone still invalidates the tuple this publish validated. + guard[key] = value ?? null; + } + + for (const [key, supplied] of Object.entries(lateFields)) { + if (supplied === undefined) continue; + const stored = captured[key] ?? null; + if (stored === null) { + apply[key] = supplied; + continue; + } + if (stored !== supplied) conflicts[key] = { stored, supplied }; + } + + if (Object.keys(conflicts).length > 0) { + return { + status: 409, + body: { + ok: false, + error: "late-fields-conflict", + reason: "this row already carries different values for these fields", + fields: conflicts, + }, + }; + } + + const pick = (key: "projectId" | "costCodeId") => { + const stored = (captured[key] ?? null) as string | null; + if (stored !== null) return { value: stored, from: "captured" as const }; + const late = lateFields[key] ?? null; + return late !== null + ? { value: late, from: "late" as const } + : { value: null, from: "none" as const }; + }; + const project = pick("projectId"); + const phase = pick("costCodeId"); + + return { + apply, + guard, + resulting: { projectId: project.value, costCodeId: phase.value }, + from: { projectId: project.from, costCodeId: phase.from }, + }; +} + +/** + * The project this finalization actually touches — checked on EVERY session + * call, not only when the request supplies a new one. + * + * The old rule authorized `lateFields.projectId` and nothing else, so access was + * only ever re-checked when the caller happened to send a project. A user whose + * access to a job had been revoked could still finalize (publish) their existing + * row on that job, and still attach a phase to it, because the request named no + * project at all — the row already had one. Revocation has to bite on the + * EFFECTIVE project: what the row will hold when this call is done. + * + * A row with no project either way is nothing to authorize: there is no job to + * be revoked from. Ownership of the row itself is a separate check. + */ +export async function authorizeEffectiveProject( + storedProjectId: string | null, + lateProjectId: string | null, + canAccessProject: (projectId: string) => Promise, +): Promise { + const effective = lateProjectId ?? storedProjectId; + if (!effective) return null; + if (await canAccessProject(effective)) return null; + return { + status: 403, + body: { + ok: false, + reason: "forbidden", + error: "project-forbidden", + projectId: effective, + }, + }; +} diff --git a/src/lib/receipt-intake/queries.ts b/src/lib/receipt-intake/queries.ts new file mode 100644 index 000000000..5809ec06c --- /dev/null +++ b/src/lib/receipt-intake/queries.ts @@ -0,0 +1,163 @@ +/** + * The one place that decides which ReceiptIntake columns leave the server. + * + * Phase 2's /automation Receipts tab reuses this unchanged, so the list route + * and the page can never disagree about what a row is. `readJson` is + * deliberately absent: it is the raw model output, kept for audit, and nothing + * outside the worker should read from it. + */ +import { prisma } from "@/lib/prisma"; +import { signReceiptDownloadUrl } from "./bucket"; +import type { RouteDeadline } from "@/lib/quickbooks"; + +export const RECEIPT_INTAKE_LIST_SELECT = { + id: true, + source: true, + sourceRef: true, + state: true, + dryRun: true, + stateReason: true, + projectId: true, + costCodeId: true, + suggestedCostCodeId: true, + suggestedConfidence: true, + createdById: true, + storagePath: true, + fileName: true, + mimeType: true, + fileSize: true, + fileSha256: true, + vendor: true, + txnDate: true, + totalCents: true, + taxCents: true, + docType: true, + refNumber: true, + memo: true, + readAt: true, + dedupStrongKey: true, + dedupWeakKey: true, + duplicateOfId: true, + qbPurchaseId: true, + expenseId: true, + archiveDriveFileId: true, + attempts: true, + // The AI-unavailable counter. Without it the Phase 2 queue page cannot tell + // "this document defeated the model" from "Gemini was down all afternoon", + // which is the first question anyone asks during an outage. + busyPasses: true, + lastError: true, + nextRetryAt: true, + bookedAt: true, + createdAt: true, + updatedAt: true, +} as const; + +/** + * What the nightly Apps Script archive mirror is allowed to see. + * + * It is a machine holding a shared secret, and its whole job is "copy this file + * to Drive under the v1 filename". It has no need for `lastError`, + * `fileSha256`, `createdById`, `dedupWeakKey`, or the retry bookkeeping — and a + * leaked or over-shared secret should expose the least that still lets the + * mirror work. Least privilege applies to a script the same way it does to a + * user. + */ +export const RECEIPT_INTAKE_ARCHIVE_SELECT = { + id: true, + sourceRef: true, + storagePath: true, + fileName: true, + mimeType: true, + txnDate: true, + vendor: true, + totalCents: true, + refNumber: true, + projectId: true, + state: true, + archiveDriveFileId: true, + bookedAt: true, + // The mirror names the Drive file `____$`, + // so it needs the project NAME, not an id it cannot resolve. + project: { select: { name: true } }, +} as const; + +/** + * Signed-URL lifetime for the archive mirror. Long enough for a nightly Apps + * Script pass to fetch every BOOKED receipt, short enough that a URL captured + * from a log is useless by morning. The bucket is private; this is the ONLY way + * the script can read the bytes, and it is deliberately a per-request grant + * rather than anything the script can store. + */ +export const ARCHIVE_SIGNED_URL_TTL_SECONDS = 600; + +/** + * States the secret caller may query. The mirror archives what is BOOKED and + * re-checks what it already ARCHIVED; nothing else is its business, and a + * `state=NEEDS_REVIEW` sweep would hand it the whole error queue. + */ +export const ARCHIVE_READABLE_STATES = new Set(["BOOKED", "ARCHIVED"]); + +export const MAX_LIST_TAKE = 200; +export const DEFAULT_LIST_TAKE = 50; + +export interface ListReceiptIntakesArgs { + state?: string | null; + projectId?: string | null; + take?: number | null; + /** Narrows the column set to RECEIPT_INTAKE_ARCHIVE_SELECT. */ + archiveOnly?: boolean; +} + +/** Newest first. `take` is clamped, never trusted from the query string. */ +export async function listReceiptIntakes(args: ListReceiptIntakesArgs) { + const take = Math.min( + MAX_LIST_TAKE, + Math.max(1, Number.isFinite(Number(args.take)) && Number(args.take) > 0 + ? Math.floor(Number(args.take)) + : DEFAULT_LIST_TAKE), + ); + return prisma.receiptIntake.findMany({ + where: { + ...(args.state ? { state: args.state } : {}), + ...(args.projectId ? { projectId: args.projectId } : {}), + }, + orderBy: { createdAt: "desc" }, + take, + select: args.archiveOnly ? RECEIPT_INTAKE_ARCHIVE_SELECT : RECEIPT_INTAKE_LIST_SELECT, + }); +} + +/** + * Attach a short-lived download URL and flatten the project name. + * + * The mirror cannot read the private bucket and must not be handed a service + * key, so each row carries its own signed URL. A row whose URL cannot be signed + * is returned with `downloadUrl: null` rather than dropped — the script logs it + * and moves on, which is strictly better than a silently short archive. + */ +export async function withArchiveDownloadUrls( + rows: T[], + /** Injectable so the contract is testable without Supabase. */ + sign: (storagePath: string, ttlSeconds: number, deadline?: RouteDeadline) => Promise = signReceiptDownloadUrl, +): Promise & { projectName: string | null; downloadUrl: string | null }>> { + return Promise.all( + rows.map(async row => { + const { project, ...rest } = row; + return { + ...(rest as Omit), + projectName: project?.name ?? null, + downloadUrl: await sign(row.storagePath, ARCHIVE_SIGNED_URL_TTL_SECONDS), + }; + }), + ); +} + +/** Dates out as ISO strings; there are no Decimals on this model, cents are Ints. */ +export function serializeReceiptIntake>(row: T) { + const out: Record = {}; + for (const [key, value] of Object.entries(row)) { + out[key] = value instanceof Date ? value.toISOString() : value; + } + return out; +} diff --git a/src/lib/receipt-intake/read.ts b/src/lib/receipt-intake/read.ts new file mode 100644 index 000000000..72eb63747 --- /dev/null +++ b/src/lib/receipt-intake/read.ts @@ -0,0 +1,393 @@ +/** + * Gemini read step — the v3.6 extraction, ported from + * qbo-clasp/runReceiptAutomation.js analyzeDriveFileWithGemini (:1081–1236). + * + * The PROMPT is verbatim from :1099–1133. It is the single most load-bearing + * string in the receipt pipeline: the final-amount rule, the never-estimate-tax + * rule, and the multi/non_receipt triage are all decisions Marge otherwise + * makes by hand, and each sentence in it was added after a specific misread. + * tests/receipt-intake-read.test.ts pins those sentences so a "tidy-up" edit + * fails loudly. ONE section is appended (the project's cost codes plus a + * "suggested_phase" output field); the v1 extraction fields stay byte-identical. + * + * The retry discipline is ported too, including the distinction the Apps Script + * learned the hard way (:1143–1184): "the service was busy" and "this document + * defeated the AI" are DIFFERENT outcomes. Collapsing them parked five legible + * receipts during the 2026-08-10..19 outage, because the caller spent one of the + * file's strikes on Google's bad day. + * + * The model list is NOT ported — the Apps Script's is 2.5-era and 404s on this + * key. Current working text model is "gemini-3.5-flash" (verified against + * ListModels 2026-08-06, see src/lib/daily-log-task-match.ts:26). + */ + +/** + * ONE row's entire read budget, models and backoffs included. + * + * The Apps Script could afford 5 retries per model with exponential backoff + * (2s..32s): it runs on a 6-minute trigger and only has to finish before the + * NEXT trigger. This worker runs inside a 60-second Vercel function that has to + * get through a batch of ten, so the same schedule would let ONE busy document + * eat the whole invocation and starve the other nine — the outage would look + * like a stalled queue rather than a slow one. A row that cannot be read in 25 + * seconds is not a row worth spending a whole run on; it comes back next pass + * at no cost to itself (AI_UNAVAILABLE never spends `attempts`). + */ +export const READ_BUDGET_MS = 25_000; +/** Retries AFTER the first attempt, per model. Three fetches per model, worst case. */ +const MAX_RETRIES = 2; +/** Backoff before retry 1 and retry 2. Short on purpose — see READ_BUDGET_MS. */ +const RETRY_BACKOFF_MS = [1_000, 3_000]; +export const GEMINI_MODELS = ["gemini-3.5-flash", "gemini-flash-latest"]; + +/** One selectable phase, rendered into the prompt as "code — name". */ +export interface ProjectPhase { + code: string; + name: string; +} + +export interface ReadResult { + /** receipt | check | multi | non_receipt */ + docType: string; + vendor: string; + /** As READ off the document — "" when unreadable. Callers apply the fallback. */ + date: string; + invoice: string; + checkNumber: string; + memo: string; + /** Raw model string; run it through cleanMoney before using it as money. */ + totalAmount: string; + taxAmount: string; + /** One of the supplied phase codes, or "". */ + suggestedPhaseCode: string; + /** + * How sure the model is about that phase, 0..1. Null when it gave no usable + * number — which is NOT the same as 0, and must not be stored as 0: "the + * model didn't say" and "the model is sure it is a poor match" would then be + * indistinguishable in the queue. + */ + suggestedConfidence: number | null; + /** The model's raw JSON text, stored for audit. */ + raw: string; +} + +export type ReadOutcome = + | { ok: true; read: ReadResult } + /** + * decisive: a model ANSWERED and still could not turn this document into + * usable data (or rejected the payload). Retrying will not change that — + * the caller must spend an attempt and route the row to a human. + * + * decisive false: every model was unavailable (429, ANY 5xx, 404, 401, + * 403, or a network error). + * The document was never read, so the caller must NOT spend an attempt. + */ + | { ok: false; decisive: boolean }; + +export interface ReadDependencies { + fetchFn: typeof fetch; + sleep: (ms: number) => Promise; + apiKey: () => string | undefined; + /** Monotonic-enough clock, injectable so the budget is testable without waiting. */ + monotonicMs: () => number; + /** Total budget for this ONE read, across every model and backoff. */ + budgetMs: number; +} + +const defaultDeps: ReadDependencies = { + fetchFn: (...args) => fetch(...args), + sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)), + apiKey: () => process.env.GEMINI_API_KEY, + monotonicMs: () => Date.now(), + budgetMs: READ_BUDGET_MS, +}; + +/** Drive returns "text/plain; charset=utf-8" — strip parameters (:1073). */ +export function normalizeMime(mime: unknown): string { + return String(mime || "").split(";")[0].trim().toLowerCase(); +} + +/** + * :1099–1133 VERBATIM, plus the appended phase section. Exported so the test + * can assert the load-bearing sentences without a network call. + */ +export function buildReadPrompt(projectPhases: ProjectPhase[]): string { + const promptText = + 'Role: Bookkeeper for "Golden Touch Remodeling", a residential remodeling contractor.\n' + + "The attached document may be:\n" + + " A) a RECEIPT / INVOICE from a store or vendor,\n" + + " B) a photo of a HANDWRITTEN CHECK the business wrote to a subcontractor, or\n" + + " C) a NON-RECEIPT such as a payment-app screenshot, payroll advances, a bank-transfer confirmation, or a chat/text-message screenshot.\n\n" + + 'STEP 1 - if the file contains MORE THAN ONE separate receipt, invoice, or check ' + + "(e.g. several receipts scanned into one PDF, or a sale AND its refund as separate pages), " + + 'return exactly {"doc_type":"multi"} and nothing else. A multi-PAGE document about ONE ' + + 'transaction is fine. Otherwise, for category C return exactly {"doc_type":"non_receipt"} and nothing else. ' + + 'For purchase documents set doc_type to "receipt" or "check".\n' + + "STEP 2 - extract ONLY these fields:\n" + + '- RECEIPT: vendor, date, invoice number (or "NoInv"), total_amount, tax_amount. ' + + "total_amount is the FINAL amount paid — after all discounts, coupons, and credits, and " + + "including tax and fees. It is the number that will match the bank/card charge. NEVER the " + + "subtotal, and never the pre-discount price. If the receipt shows both a subtotal and a " + + "total, use the total. tax_amount is the sales tax shown on the receipt (the TAX line); " + + 'return "" if no tax line is shown or it cannot be read confidently — never estimate or ' + + "compute it yourself.\n" + + '- CHECK: vendor = the "PAY TO THE ORDER OF" payee; date; total_amount from the numeric box ' + + "(cross-check it against the written-out amount line); check_number (printed top-right); " + + 'memo (the handwritten bottom-left "MEMO"/"FOR" line — what the payment is for). ' + + "Handwriting may be messy — read carefully.\n" + + 'If a field cannot be read, return "" for it. For the date, return "" rather than guessing.\n\n' + + "OUTPUT FORMAT (Strict JSON):\n" + + "{\n" + + ' "doc_type": "receipt, check, multi, or non_receipt",\n' + + ' "vendor": "String (payee for checks)",\n' + + ' "date": "YYYY-MM-DD or empty",\n' + + ' "invoice": "String (or NoInv)",\n' + + ' "check_number": "String (checks only)",\n' + + ' "memo": "String (checks only, verbatim memo line)",\n' + + ' "total_amount": "0.00",\n' + + ' "tax_amount": "0.00 (receipts only, empty if not shown)"\n' + + "}"; + + // The ONE appended section. A suggestion only — a human or the cost-code + // matcher still owns the final phase, so an empty answer is always allowed + // and an off-list answer is discarded by the caller. + // + // The confidence promise below ("a low number sends the receipt to a + // human") is KEPT, and kept in one place: RECEIPT_PHASE_CONFIDENCE_MIN in + // intake-core.ts is the threshold, and book.ts's resolvePhase is what + // withholds the suggestion. The number is deliberately NOT stated to the + // model — telling it the bar invites answers calibrated to clear the bar + // rather than to the document. + if (projectPhases.length === 0) return promptText; + const phaseList = projectPhases.map(p => `${p.code} — ${p.name}`).join("\n"); + return ( + promptText + + "\n\nSTEP 3 - this document belongs to a job with the following phases:\n" + + phaseList + + "\nAdd TWO more output fields: \"suggested_phase\", holding the CODE of the single phase " + + "this purchase most clearly belongs to (use only a code from the list above, exactly as " + + 'written; return "" if nothing on the document points clearly at one phase), and ' + + '"suggested_phase_confidence", a number from 0 to 1 for how sure you are of that phase. ' + + "Be honest about uncertainty — a low number sends the receipt to a human, which is the " + + "right outcome when the document is ambiguous." + ); +} + +/** + * The ONLY four answers STEP 1 of the prompt is allowed to give. + * + * `doc_type` used to default to "receipt" when the field was missing, and any + * unrecognised string fell through the exact `multi` / `non_receipt` checks in + * routeState and was treated as a bookable receipt too. So a truncated + * response, a schema change, or a prompt-injected document that suppressed the + * field while supplying plausible vendor/date/amount values would be routed + * straight at QuickBooks. Failing OPEN on a classifier that decides whether + * something is a purchase at all is exactly backwards. + */ +export const DOC_TYPES = ["receipt", "check", "multi", "non_receipt"] as const; +/** Not in DOC_TYPES: routeState sends it to a human. */ +export const UNKNOWN_DOC_TYPE = "unknown"; + +export function normalizeDocType(value: unknown): string { + // typeof, not coerce(): String(["receipt"]) is "receipt", so an array would + // otherwise be accepted as a valid classification. A doc_type that is not a + // string is not an answer. + if (typeof value !== "string") return UNKNOWN_DOC_TYPE; + const raw = value.trim().toLowerCase(); + return (DOC_TYPES as readonly string[]).includes(raw) ? raw : UNKNOWN_DOC_TYPE; +} + +/** + * 0..1, or null. Clamped at the edges (a model that says 1.2 means "very sure"), + * but anything non-numeric is null — never 0, because "no answer" and "sure it + * is a poor match" must stay distinguishable. + */ +export function normalizeConfidence(value: unknown): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null; + } + // `Number("")` and `Number(" ")` are BOTH 0 — a real, maximally-unconfident + // reading — so coercing first turned "the model said nothing" into "the + // model is certain this phase is wrong". Those must stay distinguishable: + // the queue sorts by this, and 0 is a signal while null is an absence. + if (typeof value !== "string") return null; + const text = value.trim(); + if (!text) return null; + const n = Number(text); + if (!Number.isFinite(n)) return null; + return Math.min(1, Math.max(0, n)); +} + +function coerce(value: unknown): string { + if (value === null || value === undefined) return ""; + return String(value).trim(); +} + +/** Map the model's JSON onto ReadResult; off-list phase suggestions are dropped. */ +export function parseReadJson(text: string, projectPhases: ProjectPhase[]): ReadResult | null { + let json: Record; + try { + json = JSON.parse(text); + } catch { + return null; + } + if (!json || typeof json !== "object") return null; + + const allowed = new Set(projectPhases.map(p => p.code)); + const suggested = coerce(json.suggested_phase); + + return { + docType: normalizeDocType(json.doc_type), + vendor: coerce(json.vendor), + date: coerce(json.date), + invoice: coerce(json.invoice), + checkNumber: coerce(json.check_number), + memo: coerce(json.memo), + totalAmount: coerce(json.total_amount), + taxAmount: coerce(json.tax_amount), + suggestedPhaseCode: allowed.has(suggested) ? suggested : "", + // Only meaningful alongside an ACCEPTED phase — a confidence attached + // to a suggestion we discarded would be actively misleading. + suggestedConfidence: allowed.has(suggested) + ? normalizeConfidence(json.suggested_phase_confidence) + : null, + raw: text, + }; +} + +/** + * Read one document. `fileBytes` is the raw file; text/plain goes in as a text + * part the way v1 does (:1093), everything else as inline_data. + */ +export async function readReceipt( + fileBytes: Buffer, + mime: string, + projectPhases: ProjectPhase[], + deps: Partial = {}, +): Promise { + const { fetchFn, sleep, apiKey, monotonicMs, budgetMs } = { ...defaultDeps, ...deps }; + const key = apiKey(); + // No key configured is a SERVICE fact, not a document fact — never spend + // the row's attempts on it. + if (!key) return { ok: false, decisive: false }; + + const mimeType = normalizeMime(mime); + const payloadPart = mimeType === "text/plain" + ? { text: "This is a text file containing receipt data:\n" + fileBytes.toString("utf8") } + : { inline_data: { mime_type: mimeType, data: fileBytes.toString("base64") } }; + + const body = JSON.stringify({ + contents: [{ parts: [{ text: buildReadPrompt(projectPhases) }, payloadPart] }], + generationConfig: { responseMimeType: "application/json" }, + }); + + // A definitive failure OUTRANKS an availability one: if any model got a + // response and still could not produce usable JSON, that is evidence about + // the DOCUMENT, and treating it as "busy" would retry a hopeless file + // forever. + let sawDecisiveFailure = false; + + const startedAt = monotonicMs(); + const remaining = () => budgetMs - (monotonicMs() - startedAt); + + for (const model of GEMINI_MODELS) { + const url = + `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}` + + `:generateContent?key=${encodeURIComponent(key)}`; + let attempts = 0; + + for (;;) { + // The budget is checked before every network call AND before every + // sleep, so an exhausted budget can never be discovered only after + // the call that blew it. + if (remaining() <= 0) break; + + let response: Response; + try { + response = await fetchFn(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + // Never outlive the row's budget: a single hung socket must + // not consume the worker's whole invocation. + signal: AbortSignal.timeout(remaining()), + }); + } catch { + // Network error / our own abort. Both are SERVICE facts. + if (attempts >= MAX_RETRIES) break; + const wait = RETRY_BACKOFF_MS[attempts]; + attempts++; + if (remaining() <= wait) break; + await sleep(wait); + continue; + } + + const code = response.status; + + if (code === 200) { + let json: { candidates?: { content?: { parts?: { text?: string }[] } }[] } | null; + try { + json = await response.json() as typeof json; + } catch { + // The body could not be read/parsed — an interrupted stream + // (abort, timeout, reset) or a truncated transfer. That is a + // SERVICE fact, not a document fact: the model never actually + // answered, so retry it the same as a network error rather + // than treating a dropped connection as "read and unreadable". + if (attempts >= MAX_RETRIES) break; + const wait = RETRY_BACKOFF_MS[attempts]; + attempts++; + if (remaining() <= wait) break; + await sleep(wait); + continue; + } + const text = json?.candidates?.[0]?.content?.parts?.[0]?.text; + // The model answered with a well-formed response; it just could + // not turn THIS document into usable data. Try the next model, + // then give up decisively. + if (!text) { sawDecisiveFailure = true; break; } + const parsed = parseReadJson(text, projectPhases); + if (parsed) return { ok: true, read: parsed }; + sawDecisiveFailure = true; + break; + } + + // EVERY 5xx is the SERVICE failing, not the document. 503 and 429 + // were already treated that way, but 500/502/504 fell through to + // the "decisive" branch below and charged the row a strike for a + // Google-side fault it had nothing to do with — precisely the + // mistake the outage rationale at :1143-1184 exists to prevent. A + // gateway error says nothing about whether the receipt is readable. + if (code === 429 || code >= 500) { // overloaded / rate-limited / server fault + if (attempts >= MAX_RETRIES) break; // fall through to the next model + const wait = RETRY_BACKOFF_MS[attempts]; + attempts++; + if (remaining() <= wait) break; + await sleep(wait); + continue; + } + + // 404 (model not available for this key) and 401/403 (revoked key, + // blocked project) are SERVICE failures: the document was never + // read, so they must not cost this row an attempt. A 404 on ONE + // model while another works is exactly what the chain is for. + if (code === 404 || code === 401 || code === 403) break; + + // What is left is a 4xx that is not 401/403/404/429: a rejected + // payload (400 = oversized or undecodable). THAT is about this + // document, and no amount of retrying changes it. + sawDecisiveFailure = true; + return { ok: false, decisive: true }; + } + + // The budget, not this model, is what ended the loop — trying the next + // model would only overrun it further. + if (remaining() <= 0) break; + } + + // Budget exhausted, or every model was unavailable: AI_UNAVAILABLE. A + // decisive failure still outranks it — if some model DID answer and could + // not read the document, that is a fact about the document and the caller + // must spend an attempt on it. + return { ok: false, decisive: sawDecisiveFailure }; +} diff --git a/src/lib/receipt-intake/receipt-url.ts b/src/lib/receipt-intake/receipt-url.ts new file mode 100644 index 000000000..ca1fc7233 --- /dev/null +++ b/src/lib/receipt-intake/receipt-url.ts @@ -0,0 +1,114 @@ +/** + * The reference an Expense stores for a receipt this pipeline booked. + * + * `Expense.receiptUrl` used to be handed a raw signed URL by some writers and a + * bare storage path by others. Both are wrong for a row that outlives them: a + * signed URL expires (ten minutes later the link in the books is dead), and a + * bare path says nothing about WHICH bucket it is in, which is exactly the + * ambiguity that made receipts and contracts share one. + * + * So the column holds a STABLE, resolvable reference — `receipt-intake:///` + * — and every reader mints a short-lived signed URL from it at read time. + * Nothing in the database expires, and nothing dereferences a caller-supplied + * URL. + */ +import { prisma } from "@/lib/prisma"; +import { RECEIPT_BUCKET, signReceiptDownloadUrl } from "./bucket"; +import type { RouteDeadline } from "@/lib/quickbooks"; + +export const RECEIPT_URL_SCHEME = "receipt-intake://"; + +/** Ten minutes: long enough to open, short enough that a leaked link is inert. */ +export const RECEIPT_URL_TTL_SECONDS = 600; + +export function receiptUrlRef(storagePath: string, bucket: string = RECEIPT_BUCKET): string { + return `${RECEIPT_URL_SCHEME}${bucket}/${storagePath}`; +} + +export function isReceiptUrlRef(value: string | null | undefined): boolean { + return typeof value === "string" && value.startsWith(RECEIPT_URL_SCHEME); +} + +export function parseReceiptUrl(value: string | null | undefined): { bucket: string; path: string } | null { + if (!isReceiptUrlRef(value)) return null; + const rest = (value as string).slice(RECEIPT_URL_SCHEME.length); + const slash = rest.indexOf("/"); + if (slash <= 0) return null; + const bucket = rest.slice(0, slash); + const path = rest.slice(slash + 1); + // Only OUR bucket, and never a traversal: this string ends up in a storage + // API call, and it is read out of a database column that other code writes. + if (bucket !== RECEIPT_BUCKET) return null; + if (!path || path.startsWith("/") || path.includes("..")) return null; + return { bucket, path }; +} + +export interface ReceiptUrlDeps { + sign: (storagePath: string, ttlSeconds: number, deadline?: RouteDeadline) => Promise; + /** Where the intake row that owns this object points NOW. */ + currentPath: (storagePath: string) => Promise; +} + +const defaultDeps: ReceiptUrlDeps = { + sign: signReceiptDownloadUrl, + currentPath: async storagePath => { + // Found through the Expense that carries this exact reference: the + // intake row is the thing that tracks where the bytes ARE, and the ref + // records where they were when the Purchase was written. + const row = await prisma.receiptIntake.findFirst({ + where: { expense: { receiptUrl: receiptUrlRef(storagePath) } }, + select: { storagePath: true }, + }); + return row?.storagePath ?? null; + }, +}; + +/** + * Mint a short-lived signed URL for a stored reference, or null. + * + * THE OBJECT MOVES. A row is published at the upload path, sealed to a + * content-addressed one, and later archived — and the Expense was written + * before some of that happened. So a reference that no longer resolves is + * re-asked of the intake row, which is the thing that actually tracks where the + * bytes are, before giving up. + * + * Never throws: a receipt that cannot be linked must render as "no receipt", + * not take the expenses tab down. + */ +export async function resolveReceiptUrl( + value: string | null | undefined, + ttlSeconds: number = RECEIPT_URL_TTL_SECONDS, + deps: ReceiptUrlDeps = defaultDeps, +): Promise { + const parsed = parseReceiptUrl(value); + if (!parsed) return null; + const direct = await deps.sign(parsed.path, ttlSeconds).catch(() => null); + if (direct) return direct; + // Moved (sealed or archived) since the Expense was written. + const moved = await deps.currentPath(parsed.path).catch(() => null); + if (!moved || moved === parsed.path) return null; + return await deps.sign(moved, ttlSeconds).catch(() => null); +} + +/** + * Resolve `receipt-intake://` references on a batch of rows to short-lived + * signed URLs, in parallel. A non-reference value (a legacy absolute URL, a + * data URL, or null) passes through unchanged — same rule as resolveReceiptUrl, + * just applied across a list instead of one row at a time. + * + * Every reader that renders `receiptUrl` as an href — the bookkeeper queue, + * the project expenses tab — must resolve it first: the column stores the + * stable reference book.ts writes, not a link a browser can open. + */ +export async function resolveReceiptUrls( + rows: T[], + ttlSeconds: number = RECEIPT_URL_TTL_SECONDS, + deps: ReceiptUrlDeps = defaultDeps, +): Promise { + return Promise.all(rows.map(async row => ({ + ...row, + receiptUrl: isReceiptUrlRef(row.receiptUrl) + ? await resolveReceiptUrl(row.receiptUrl, ttlSeconds, deps) + : row.receiptUrl, + }))); +} diff --git a/src/lib/receipt-intake/route-state.ts b/src/lib/receipt-intake/route-state.ts new file mode 100644 index 000000000..e8ef7d962 --- /dev/null +++ b/src/lib/receipt-intake/route-state.ts @@ -0,0 +1,201 @@ +/** + * Pure routing decision for a freshly-read intake row + * (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §4). No I/O: the caller does the + * dedup lookups and hands the hits in, so the whole truth table is unit + * testable (tests/receipt-intake-route-state.test.ts). + */ + +export const RECEIPT_INTAKE_STATES = [ + // STAGING: the row exists but its file does not yet. Never claimable. + "STAGING", + "RECEIVED", "READ", "NEEDS_JOB", "NEEDS_REVIEW", "BOOKING", + "BOOKED", "ARCHIVED", "DUPLICATE", "VOID", "NON_RECEIPT", + /** + * Terminal. The row arrived while RECEIPT_INTAKE_DRYRUN was on, so v1 (the + * Apps Script) booked it and v2 never will. See the cutover sequence in + * docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §7. + */ + "SHADOW_DONE", + /** + * Terminal, and it needs a person. + * + * Pre-boundary, no evidence v1 booked it, and NOT a Drive row — so there is + * no shared identity that would make a v2 booking idempotent against a + * Purchase v1 may or may not have created. Booking it risks a duplicate; + * retiring it risks losing a real expense. Neither is ours to guess, so it + * surfaces on the Receipts tab with a "book anyway" action for whoever has + * checked QuickBooks. NEVER auto-requeued. + */ + "SHADOW_QUARANTINE", +] as const; + +export type ReceiptIntakeState = (typeof RECEIPT_INTAKE_STATES)[number]; + +/** Mirrors DOC_TYPES in read.ts — the closed set STEP 1 of the prompt may return. */ +const KNOWN_DOC_TYPES = new Set(["receipt", "check", "multi", "non_receipt"]); + +export interface RouteInput { + docType: string; + /** cleanMoney output, e.g. "0.00" / "364.98". */ + amount: string; + /** Integer cents for this row, used to compare against a strong-key owner. */ + totalCents: number | null; + /** canonicalVendor() of this document — see the vendor-mismatch rule below. */ + canonicalVendor: string; +} + +export interface DedupHits { + /** + * The row that already owns this document's strong key, if any. Discovered + * by the partial unique index rejecting our claim — the database IS the + * lock (pgbouncer forbids session advisory locks). + */ + strong: { id: string; totalCents: number | null; canonicalVendor: string | null } | null; + /** Another LIVE row carrying the same weak key. Always routes to a human. */ + weak: { id: string } | null; +} + +export interface RouteDecision { + state: ReceiptIntakeState; + stateReason: string | null; + duplicateOfId: string | null; +} + +/** + * First match wins. Order is the spec's, and it matters: + * - multi/non_receipt are triage answers about the FILE, decided before money. + * - a total that is zero OR NEGATIVE never books automatically. A $0.00 is + * almost always a misread (:531 — you don't get a $0 receipt or write a $0 + * check); a negative total is a refund, which is a legitimate document that + * a human must place against the original purchase. Both are decided BEFORE + * any dedup key is claimed, so neither can quarantine the real receipt that + * arrives next. + * - no project means nobody can job-cost it yet; that is a queue, not a fault. + * - a strong hit at the SAME total is the same purchase arriving twice — + * UNLESS the two documents name different vendors. The v3.6 key is + * deliberately vendor-less (:1545–1557: one store's own formats spell its + * name three ways, and keying on the vendor put one purchase on two keys), + * and that rationale stands. But the cost of leaving the vendor out is that + * two UNRELATED vendors reusing an invoice number on one day for the same + * amount now collide, and auto-quarantining one of them would silently drop + * a real expense. So the vendor is not part of the KEY, but it is part of + * the CONFIRMATION: a mismatch downgrades to a human. + * A strong hit at a DIFFERENT total is ambiguous the other way (a misread + * total) and also goes to a human — never resolved on a guess. + * - a weak hit is only a POSSIBLE duplicate (two genuine same-day purchases + * from one vendor for the same amount do happen), so it always asks a + * human (:1591–1596). + */ +export function routeState(read: RouteInput, dedupHits: DedupHits, hasProject: boolean): RouteDecision { + const docType = String(read.docType || "").toLowerCase(); + + if (docType === "multi") { + return { state: "NEEDS_REVIEW", stateReason: "multi-doc", duplicateOfId: null }; + } + if (docType === "non_receipt") { + return { state: "NON_RECEIPT", stateReason: null, duplicateOfId: null }; + } + // Fail CLOSED on the classifier. A missing or unrecognised doc_type means + // we do not know whether this is a purchase at all — a truncated response, a + // schema change, or a prompt-injected document that suppressed the field + // while supplying plausible amounts. Booking on that is unacceptable; a + // human looks instead. + if (!KNOWN_DOC_TYPES.has(docType)) { + return { state: "NEEDS_REVIEW", stateReason: "unknown-doc-type", duplicateOfId: null }; + } + if (read.totalCents === null || read.totalCents <= 0 || read.amount === "0.00") { + return { state: "NEEDS_REVIEW", stateReason: "refund-or-zero", duplicateOfId: null }; + } + if (!hasProject) { + return { state: "NEEDS_JOB", stateReason: null, duplicateOfId: null }; + } + if (dedupHits.strong) { + // A null owner total means a claim we cannot confirm the amount of — + // read that as "can't confirm the totals match", never as a match. + const sameTotal = + dedupHits.strong.totalCents !== null && + read.totalCents !== null && + dedupHits.strong.totalCents === read.totalCents; + if (sameTotal) { + // An owner whose vendor we don't know is not a confirmed match + // either — the same "can't confirm" reasoning as a null total. + const sameVendor = + !!dedupHits.strong.canonicalVendor && + !!read.canonicalVendor && + dedupHits.strong.canonicalVendor === read.canonicalVendor; + if (!sameVendor) { + return { + state: "NEEDS_REVIEW", + stateReason: `vendor-mismatch:${dedupHits.strong.id}`, + duplicateOfId: dedupHits.strong.id, + }; + } + return { state: "DUPLICATE", stateReason: null, duplicateOfId: dedupHits.strong.id }; + } + return { + state: "NEEDS_REVIEW", + stateReason: `strong-dup-amount-mismatch:${dedupHits.strong.id}`, + duplicateOfId: dedupHits.strong.id, + }; + } + if (dedupHits.weak) { + return { + state: "NEEDS_REVIEW", + stateReason: `weak-dup:${dedupHits.weak.id}`, + duplicateOfId: null, + }; + } + return { state: "READ", stateReason: null, duplicateOfId: null }; +} + +/** + * The one dropped-tax-reading marker the reader appends to `stateReason` (see + * worker.ts's `note()`), separate from every other value that column carries + * (park reasons, weak-dup pointers, defer reasons). It must survive + * transitions that otherwise clear or overwrite `stateReason` on the way to + * BOOKED — an automatically booked receipt with a bad tax read must stay + * distinguishable from one with no tax read at all. + */ +export const TAX_IMPLAUSIBLE_REASON = "tax-implausible"; + +/** + * THE WARNING HAS ITS OWN COLUMN, because `stateReason` is not durable. + * + * Routing wrote the marker into `stateReason`, and everything downstream + * then overwrote that column for its own reasons: a deferred booking + * replaces it with "push-disabled" or "push-paused", a park with a park + * reason. The BOOKED transition read the marker out of whatever the column + * happened to hold at that moment, so any row that took the deferred path -- + * which is EVERY row during the disabled-push cutover -- reached BOOKED with + * the evidence already erased. An automatically booked receipt with a bad tax + * read became indistinguishable from one with a clean read. + * + * `taxWarning` is written once, by routing, and nothing else touches it. + * + * `stateReason` is still consulted as a FALLBACK, for rows that were already + * mid-flight when the column was added: one sitting in BOOKING carrying the + * marker in the old place must not lose it at deploy time. + */ +export function preservedTaxWarning(row: { + taxWarning?: string | null; + stateReason?: string | null; +}): string | null { + if (row.taxWarning === TAX_IMPLAUSIBLE_REASON) return TAX_IMPLAUSIBLE_REASON; + return (row.stateReason ?? "").split(";").includes(TAX_IMPLAUSIBLE_REASON) + ? TAX_IMPLAUSIBLE_REASON + : null; +} + +/** + * Retry backoff for the booking step: attempts 1 gives 5m, 2 gives 15m, + * 3 gives 1h, 4+ gives 6h. Exported here (rather than in book.ts) so the + * schedule is testable without pulling QuickBooks into the test process. + */ +export const MAX_BOOK_ATTEMPTS = 20; + +export function backoffMs(attempts: number): number { + if (attempts <= 1) return 5 * 60_000; + if (attempts === 2) return 15 * 60_000; + if (attempts === 3) return 60 * 60_000; + return 6 * 60 * 60_000; +} diff --git a/src/lib/receipt-intake/storage-cleanup.ts b/src/lib/receipt-intake/storage-cleanup.ts new file mode 100644 index 000000000..ffef9288f --- /dev/null +++ b/src/lib/receipt-intake/storage-cleanup.ts @@ -0,0 +1,1160 @@ +/** + * Orphaned-object bookkeeping. + * + * When an intake row is rejected (oversize, wrong format, empty) the object it + * pointed at has to go too — the row is deleted, so after that NOTHING in the + * database references those bytes and they would sit in a private bucket + * forever. Storage deletes fail for the same boring reasons every other call + * does, so "best effort, shrug" is not good enough on a path we take + * deliberately. + * + * An AutomationEvent is used rather than a new table: this is rare, it is + * already the audit surface the Command Center reads, and a table for it would + * be schema churn for a queue that should normally be empty. + */ +import { randomUUID } from "node:crypto"; +import type { Prisma } from "@prisma/client"; +import { logAutomationEvent } from "@/lib/automation-events"; +import { prisma } from "@/lib/prisma"; +import { removeReceiptObject, STORAGE_CALL_MAX_MS, uploadReceiptObject } from "./bucket"; +import { leaseFence } from "./stored-object"; +import type { RouteDeadline } from "@/lib/quickbooks"; + +export const STORAGE_CLEANUP_KIND = "storage-cleanup-pending"; + +/** + * `pending` — the object exists and is unreferenced; delete it when due. + * `provisional` — an INTENT for an object a publish is about to write. Same + * sweep, but invisible to `reclaimQueuedCleanups` so the publish that took it + * out cannot cancel it by taking the very lock it needs. See queueCanonicalIntent. + */ +export type CleanupStatus = "pending" | "provisional"; +export const CLEANUP_SWEEPABLE_STATUSES: CleanupStatus[] = ["pending", "provisional"]; + +/** The one write `resolveCanonicalIntent` needs — injectable for tests. */ +export interface CleanupResolveTx { + automationEvent: { + update(args: { + where: { id: string }; + data: { status: string; reason: string }; + }): Promise; + }; +} + +/** + * THE SCHEDULE ON A QUEUED CLEANUP, and why one is needed at all. + * + * Deleting an object is not the same as making it undeletable-again. While a + * signed upload URL for the path is still valid, its holder's delayed PUT + * recreates the object AFTER we removed it — and by then the row that named + * the path is gone, so nothing references those bytes, nothing remembers them, + * and no sweep is looking for them. That is an unreferenced object in a + * private bucket, forever, created by our own cleanup. + * + * So a cleanup carries the instant it becomes safe (`cleanupNotBefore()`: + * the upload lease's own deadline plus a grace), and nothing acts on it before + * then. The event IS the tombstone — it outlives the row and holds the path + * and the expiry — which is what lets the row be deleted immediately while the + * OBJECT waits for the capability to die. + * + * Stored in `detail` rather than a column: AutomationEvent has no schedule + * field, this queue is normally empty, and adding one would be a production + * migration for a five-minute delay. + */ +export function cleanupDueAt(detail: string | null | undefined): Date | null { + if (!detail) return null; + let parsed: { notBefore?: unknown }; + try { + parsed = JSON.parse(detail) as { notBefore?: unknown }; + } catch { + return null; + } + if (typeof parsed.notBefore !== "string") return null; + const at = new Date(parsed.notBefore); + // An unparseable schedule is NOT permission to wait forever — it would + // wedge the event in the queue every pass with nothing able to clear it. + return Number.isNaN(at.getTime()) ? null : at; +} + +/** Is a queued cleanup allowed to run yet? No schedule means "now". */ +export function cleanupDue(detail: string | null | undefined, now: Date = new Date()): boolean { + const due = cleanupDueAt(detail); + return !due || due.getTime() <= now.getTime(); +} + +/** + * The storage and queue writes the two request-path cleanups make. Injected + * for the same reason the sweep's are: "nothing was deleted" is only a real + * assertion if a test can see the delete not happening. + */ +export interface CleanupIo { + remove: (storagePath: string) => Promise; + record: (storagePath: string, reason: string, notBefore: Date | null) => Promise; + resolve: (eventId: string) => Promise; + now: () => Date; +} + +const liveIo: CleanupIo = { + // The sweep runs inside the worker's invocation; its own deadline is + // threaded in by the caller through `retryPendingCleanups`. + remove: (storagePath: string) => removeReceiptObject(storagePath, undefined), + record: recordPendingCleanup, + resolve: async eventId => { + await prisma.automationEvent + .update({ where: { id: eventId }, data: { status: "resolved" } }) + .catch(() => { /* the sweep will find it still pending and re-check */ }); + }, + now: () => new Date(), +}; + +/** A schedule that has not arrived yet, or null when the delete may run now. */ +function pending(notBefore: Date | null | undefined, now: Date): Date | null { + return notBefore && notBefore.getTime() > now.getTime() ? notBefore : null; +} + +/** + * ONE OBJECT PATH, ONE WRITER — publication or cleanup, never both at once. + * + * The two used to be free-running, and each was individually careful in a way + * that only worked if the other was not there: + * + * - the sweep checks "does a live row point at this path" and then deletes + * the object, in two separate operations; + * - a publish seals the bytes at the canonical path and only THEN commits the + * row pointer at it, in two separate operations — deliberately, because + * committing a pointer to bytes that were never written is unrecoverable. + * + * Interleave them and the guards cancel out. The sweep looks while the row + * still points at the UPLOAD path, sees nothing referencing the canonical one, + * and deletes the object the publish just sealed; the publish then commits, and + * deletes the upload copy because the pointer moved. The row is RECEIVED, its + * sha is recorded, every later reader verifies against it — and the bytes are + * gone. A successful intake, pointing at nothing. + * + * A transaction-scoped advisory lock on the path is what makes the pairs + * atomic with respect to each other. Transaction scoped, not session scoped, + * because the app talks to Postgres through pgbouncer in transaction pooling + * mode: a session lock would be taken on a connection that is handed to + * somebody else the moment the statement ends, and released never. + * + * `hashtext` collisions are harmless here — two unrelated paths sharing a hash + * serialize against each other for a few milliseconds and nothing more. + */ +/** + * A SHORT transaction. No storage call may be awaited inside it. + * + * This replaces `withReceiptObjectLock`, which took a transaction-scoped + * advisory lock on the path and then ran a Supabase round trip inside it. The + * lock was correct about mutual exclusion and wrong about what it cost: a + * pooled connection was held for the whole of an external call the round-16 + * deadline caps at fifteen seconds, so a handful of concurrent finalizations + * exhausted the five-connection pool and later requests could not reach the + * database at all — including to release what they had claimed. + * + * The exclusion it provided is now a LEASE recorded in the cleanup queue (see + * claimObjectPath), which needs no open transaction to hold. + * + * The timeout is short on purpose: a body that cannot finish in five seconds + * without external I/O is doing something this comment says it must not. + */ +export async function inShortTx(body: (tx: Prisma.TransactionClient) => Promise): Promise { + return prisma.$transaction(body, { maxWait: 5_000, timeout: 5_000 }); +} + +/** + * How long a publish may hold a path before the sweeper presumes it dead. + * + * Comfortably longer than STORAGE_CALL_MAX_MS (15s), because the lease has to + * cover the seal plus the settle transaction that follows it; short enough + * that a killed invocation's paths are collectable within a couple of sweeps. + */ +export const OBJECT_CLAIM_LEASE_MS = 60_000; + +/** + * WHEN A CLAIMED PATH BECOMES COLLECTABLE — the lease, as a pure decision. + * + * The LATER of two deadlines, and both matter: + * - this publish's own lease, which is what stops the sweeper collecting the + * object between the seal and the pointer commit (the window the advisory + * lock used to hold open with a connection); + * - the caller's schedule, when a still-live signed upload URL protects the + * path for longer than the publish will take. + * + * Taking the caller's alone would leave the seal window unguarded whenever the + * upload lease had already lapsed — which is the sweeper's own publish path, + * every time. + */ +export function objectClaimDueAt(notBefore: Date | null, now: Date = new Date()): Date { + const leaseUntil = new Date(now.getTime() + OBJECT_CLAIM_LEASE_MS); + return notBefore && notBefore > leaseUntil ? notBefore : leaseUntil; +} + +/** + * PHASE A OF THE PUBLISH: claim a canonical path without holding a connection. + * + * One short transaction that does two things which must happen together: + * - cancels any PENDING deletion of this path. The queue may hold an entry + * from an earlier attempt, and it is wrong about the object this publish + * is about to write. + * - records a PROVISIONAL intent carrying a lease. While that lease is live + * the sweeper skips the path (its schedule is in the future), so the + * object cannot be collected between the seal and the pointer commit — + * the window the advisory lock used to hold open with a connection. + * + * Returns the intent id, which phase C resolves in the same transaction as + * the pointer. An intent left behind by a publish that died simply lapses and + * the sweeper reclaims it, re-checking live references before it deletes. + */ +export async function claimObjectPath( + canonicalPath: string, + /** When the object may be deleted once the lease lapses. */ + notBefore: Date | null = null, + now: Date = new Date(), + /** + * The transaction runner. Injected ONLY by tests: what this function + * writes is the whole subject, and a verdict that persists nothing is + * exactly the bug this claim exists to close. + */ + run: (body: (tx: Prisma.TransactionClient) => Promise) => Promise = inShortTx, +): Promise { + const until = objectClaimDueAt(notBefore, now); + // ONE TRANSACTION: read what holds the path, cancel any stale queued + // deletion of it, and write this publish's claim. Split across two, a + // deleter could take the path between the read and the write — which is + // the very interleaving this claim exists to stop. + return run(async tx => { + // THE LOCK FIRST, then the claim -- see acquireObjectClaim. Reading + // 'is this path free' and writing 'it is mine' have to be one step + // against every other claimant, and they were not: this insert and + // the sweeper's update touched DIFFERENT rows, so both could read a + // free path and both commit. + const claim = await acquireObjectClaim(tx, canonicalPath, "publishing", until, now); + if (!claim.ok) throw new ObjectPathBusyError(canonicalPath, claim.heldBy); + await reclaimQueuedCleanups(tx, canonicalPath); + // The CLEANUP INTENT, which is a different thing from the claim: it is + // what the sweeper acts on if this publish dies. The claim above is + // what stops anyone deleting the path while it is alive. + return queueObjectCleanup( + tx, + canonicalPath, + "canonical-seal-intent", + until, + "provisional", + { token: claim.token, kind: "publishing", until }, + ); + }); +} + +/** + * A queued deletion for a path a publish is, right now, putting bytes at. + * + * Resolved rather than left pending: the event says "nothing references these + * bytes, remove them", and the publish holding this lock is in the middle of + * making that false. Leaving it pending would let the next sweep delete the + * object the moment this lock is released — the sweep's own reference check + * cannot save it, because a publish that has sealed but not yet committed is + * exactly the window in which no row references the path. + * + * Run BEFORE the seal, not after it. The canonical path is a deterministic + * function of the row, its upload lease and the bytes' own hash, so a publish + * that fails and is retried targets the SAME path again — the queued deletion + * is wrong about that object whether or not this particular attempt gets there. + * + * Matched on the JSON-quoted path so a prefix cannot widen it: the detail is + * `{"storagePath":"",...}`, and the quotes bound both ends. + */ +async function reclaimQueuedCleanups(tx: Prisma.TransactionClient, storagePath: string): Promise { + await tx.automationEvent.updateMany({ + where: { + kind: STORAGE_CLEANUP_KIND, + status: "pending", + detail: { contains: JSON.stringify(storagePath) }, + }, + data: { status: "resolved", reason: `reclaimed by the publish of ${storagePath}`.slice(0, 500) }, + }); +} + + +/** + * Copy verified bytes to their canonical path and drop the upload path. + * + * The upload path stays writable by whoever holds the signed URL (which is + * `upsert: true`, deliberately, so a resumed /start can replace its own partial + * upload). Leaving the row pointed at it means the bytes we verified can be + * replaced afterwards by anyone who kept the URL — the row would still claim + * the old sha while storage held something else. + * + * Returns null when the copy fails, so the caller can refuse rather than + * publish a row pointing at a path that may not exist. + */ +export async function sealObject( + uploadPath: string, + canonicalPath: string, + bytes: Buffer, + contentType: string, + /** + * REQUIRED. The seal is one of three storage calls a /finalize makes, and + * with an optional deadline each took a fresh fifteen seconds — 45s inside + * a handler the platform kills at 30. + */ + deadline: RouteDeadline | undefined, +): Promise { + // upsert: the canonical path is content-addressed, so a re-seal of the + // SAME bytes is a no-op by construction and must not fail. + const copied = await uploadReceiptObject(canonicalPath, bytes, contentType, { upsert: true, deadline }); + if (!copied) return null; + + // NOTE: the upload object is deliberately NOT deleted here. + // + // Deleting before the row is committed is unrecoverable: if the UPDATE then + // fails, the row still points at a path whose object we just removed, and + // the receipt is gone with nothing left to retry from. The caller deletes + // only after the pointer is committed — see finalizeAndPublish. + return canonicalPath; +} + +/** + * Queue an object for deletion WITHOUT attempting one first. + * + * For the ambiguous case: an upload that errored may still have written bytes, + * and the row that points at them is about to be deleted. Recording the path + * before that happens is the only way the orphan stays findable. + */ +export async function recordPendingCleanup( + storagePath: string, + reason: string, + /** When the object may be deleted — see cleanupDueAt. Null means now. */ + notBefore: Date | null = null, + /** + * `provisional` records an INTENT taken out before an external write, for + * an object that does not exist yet — see queueCanonicalIntent. It is + * deliberately invisible to `reclaimQueuedCleanups`, which would otherwise + * cancel the intent the moment the publish that took it out grabbed the + * path's lock. The sweeper picks up both statuses. + */ + status: CleanupStatus = "pending", + /** The writer. Injected only by tests — see queueObjectCleanup. */ + client: CleanupQueueTx = prisma, +): Promise { + // A DIRECT, THROWING `create` — never logAutomationEvent + a search. + // + // logAutomationEvent is fire-and-forget by contract: it swallows its insert + // failure. The read-back that compensated for that searched for ANY event + // whose detail CONTAINED this path, newest first — so on a retry, where an + // older provisional event for the same canonical path already exists, a + // FAILED insert returned that old event's id and its stale deadline as if + // the write had just succeeded. The publish then sealed its object under a + // claim it did not hold, against a lease that might already have lapsed. + // + // `create` returns the id of the row it actually wrote, or throws. There is + // nothing to search for and nothing to mistake it for. + return queueObjectCleanup(client, storagePath, reason, notBefore, status); +} + +/** + * A CLEANUP INTENT FOR AN OBJECT THAT DOES NOT EXIST YET. + * + * `sealAndPublish` writes the canonical copy to Supabase BEFORE the database + * CAS that points a row at it. Everything after that write can fail — the + * commit, the winner lookup, the transaction itself — and the object is then + * in the bucket with nothing referencing it, nothing remembering it, and no + * sweep looking for it, because the stale-STAGING sweep reads ROWS. A re-arm + * later moves the row somewhere else and the sealed copy is undiscoverable. + * + * So the intent is taken out FIRST, in its own committed transaction, and the + * publish that succeeds cancels it in the SAME transaction as the pointer + * commit. Anything left provisional is swept on schedule by + * `retryPendingCleanups`, which rechecks live references inside the path lock + * before it deletes — so an intent that outlived a publish which actually + * worked resolves harmlessly instead of destroying a live receipt. + */ + +/** Cancel an intent because the object it covers is now referenced by a row. */ +export async function resolveCanonicalIntent( + tx: CleanupResolveTx, + eventId: string, +): Promise { + await tx.automationEvent.update({ + where: { id: eventId }, + data: { status: "resolved", reason: "published" }, + }); +} + +/** The one write `queueObjectCleanup` needs — injectable, so it is testable. */ +export interface CleanupQueueTx { + automationEvent: { + create(args: { + // The CONCRETE shape, not Record: a real + // Prisma.TransactionClient is passed here (unlike RejectTxClient, + // which is reached through a cast), and Prisma's own create only + // accepts an argument type that names its required columns. + data: { kind: string; status: string; reason: string; source: string; detail: string }; + select: { id: true }; + }): Promise<{ id: string }>; + }; +} + +/** + * ENQUEUE A CLEANUP INSIDE SOMEBODY ELSE'S TRANSACTION. + * + * The counterpart to `recordPendingCleanup`'s durability rule, for the callers + * that have a transaction: the event is written with the caller's `tx`, so it + * commits with the pointer transition that orphaned the object or not at all. + * A failure here throws and takes that transition down with it, which is the + * correct outcome — a pointer that moved without its cleanup recorded is bytes + * nothing will ever find. + * + * No read-back, unlike recordPendingCleanup: this write is a plain `create` + * inside a transaction the caller commits, so it either raises or is part of + * that commit. (recordPendingCleanup goes through logAutomationEvent, which is + * fire-and-forget by contract, and that is what the read-back is there for.) + */ +export async function queueObjectCleanup( + tx: CleanupQueueTx, + storagePath: string, + reason: string, + notBefore: Date | null = null, + status: CleanupStatus = "pending", + /** The per-path claim this entry carries, when it IS one. See claimObjectPath. */ + claim: { token: string; kind: ObjectClaimKind; until: Date } | null = null, +): Promise { + const event = await tx.automationEvent.create({ + data: { + kind: STORAGE_CLEANUP_KIND, + status, + reason: reason.slice(0, 500), + source: "receipt-intake", + detail: JSON.stringify({ + storagePath, + ...(notBefore ? { notBefore: notBefore.toISOString() } : {}), + ...(claim + ? { claimToken: claim.token, claimKind: claim.kind, claimUntil: claim.until.toISOString() } + : {}), + }), + }, + select: { id: true }, + }); + return event.id; +} + +/** + * THE PER-PATH MUTEX BOTH CLAIM TRANSACTIONS TAKE AS THEIR FIRST STATEMENT. + * + * Reading the claim state and writing a claim have to be one atomic step + * against every OTHER claimant of the same path. They were not: a sweeper + * converting an EXPIRED provisional intent into a deleting claim UPDATEs that + * event row, while a publisher taking the path INSERTs a new one -- different + * rows, so at READ COMMITTED neither transaction blocks the other, both read + * 'the path is free', and both commit. The sweeper then deleted the object the + * publisher had sealed but not yet pointed at, leaving a RECEIVED row with no + * bytes behind it. + * + * Transaction-scoped, so it is released by COMMIT or ROLLBACK and a crashed + * claimant cannot wedge a path. It is taken FIRST in both transactions, so the + * two can only ever run one after the other, and the second sees what the + * first wrote. + * + * This is a LOCK, not I/O: it reaches nothing outside Postgres, and the + * transactions that hold it do no external work (see the tripwire in + * tests/receipt-intake-lease-fence.test.ts). + */ +export const OBJECT_LOCK_PREFIX = "receipt-object:"; + +export async function lockObjectPath(tx: Prisma.TransactionClient, storagePath: string): Promise { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${OBJECT_LOCK_PREFIX + storagePath}))`; +} + +/** What a claim attempt found. */ +export type ObjectClaimAttempt = + | { ok: true; token: string } + | { ok: false; heldBy: ObjectClaimKind }; + +/** + * TAKE THE PATH, or find out who holds it -- under the lock, against a table + * whose PRIMARY KEY is the path itself. + * + * One row per path IS the invariant: a second live claim is impossible even if + * the lock above were somehow missed, because there is nowhere to put it. The + * claim used to live in an AutomationEvent's JSON `detail`, where no + * constraint could express that. + * + * The exclusion rule is unchanged: two PUBLISHERS may share a path (it is + * content-addressed, so they are writing identical bytes and the seal is an + * upsert -- only the pointer needs serializing, and the publish CAS does that), + * two deleters may not, and the two kinds may never cross. + */ +export async function acquireObjectClaim( + tx: Prisma.TransactionClient, + storagePath: string, + want: ObjectClaimKind, + until: Date, + now: Date, +): Promise { + await lockObjectPath(tx, storagePath); + const held = await tx.receiptObjectClaim.findUnique({ where: { storagePath } }); + if (held && held.expiresAt.getTime() > now.getTime()) { + const heldBy = held.kind as ObjectClaimKind; + if (heldBy !== want || want === "deleting") return { ok: false, heldBy }; + } + // A LAPSED claim is taken over rather than respected: a dead holder must + // not block a path forever. The token changes with it, which is what makes + // the holder's own pre-delete re-read able to tell that it lost the path. + const token = randomUUID(); + await tx.receiptObjectClaim.upsert({ + where: { storagePath }, + create: { storagePath, token, kind: want, expiresAt: until }, + update: { token, kind: want, expiresAt: until }, + }); + return { ok: true, token }; +} + +/** + * HOW LONG A CLAIM TAKEN IMMEDIATELY BEFORE A REMOTE DELETE MUST LIVE. + * + * Strictly longer than the delete it covers. `removeReceiptObject` is bounded + * by STORAGE_CALL_MAX_MS, so a claim of that plus a margin cannot expire + * while its own delete is still in flight -- which is the failure this + * exists for: the sweep derived every expiry from a `now` captured at the + * top of the pass, so a late item could spend ten seconds in its two short + * transactions and then start a fifteen-second delete under a claim with + * seconds left. Once it lapsed a publisher took the path, sealed the + * canonical object, and the delete still in flight removed the bytes it had + * just published: a RECEIVED row pointing at nothing. + */ +export const DELETE_CLAIM_LEASE_MS = STORAGE_CALL_MAX_MS + 15_000; + +/** + * CONFIRM THE CLAIM IS STILL OURS, AND EXTEND IT, in one locked step. + * + * Called immediately before the remote delete, with CURRENT time -- never + * the pass's opening timestamp. A confirmation that does not also extend is + * a promise about the instant it was taken, and the delete outlives that + * instant. + */ +export async function renewObjectClaim( + tx: Prisma.TransactionClient, + storagePath: string, + want: ObjectClaimKind, + token: string, + until: Date, + now: Date, +): Promise { + await lockObjectPath(tx, storagePath); + const held = await tx.receiptObjectClaim.findUnique({ where: { storagePath } }); + if (!held + || held.token !== token + || held.kind !== want + || held.expiresAt.getTime() <= now.getTime()) { + return false; + } + await tx.receiptObjectClaim.update({ where: { storagePath }, data: { expiresAt: until } }); + return true; +} + +/** Is `token` still the live claim over `storagePath`, of the kind we took? */ +export async function claimIsStillOurs( + tx: Prisma.TransactionClient, + storagePath: string, + want: ObjectClaimKind, + token: string, + now: Date, +): Promise { + const held = await tx.receiptObjectClaim.findUnique({ where: { storagePath } }); + return !!held + && held.token === token + && held.kind === want + && held.expiresAt.getTime() > now.getTime(); +} + +/** Give the path back. Best effort: a lapsed claim is collected by the next claimant. */ +export async function releaseObjectClaim( + tx: Prisma.TransactionClient, + storagePath: string, + token: string, +): Promise { + await tx.receiptObjectClaim.deleteMany({ where: { storagePath, token } }); +} +/** + * The path is held by the other kind of operation right now. Retryable: the + * holder's lease is short and the caller comes back on its own schedule. + */ +export class ObjectPathBusyError extends Error { + name = "ObjectPathBusyError"; + constructor(storagePath: string, heldBy: string) { + super(`${storagePath} is held by a ${heldBy} claim`); + } +} + +/** Which operation holds a path. Exactly one may hold it at a time. */ +export type ObjectClaimKind = "publishing" | "deleting"; + +interface ObjectClaim { + token: string; + kind: ObjectClaimKind; + until: Date; +} + +/** The claim an event carries, if it is still live. */ +export function liveClaim(detail: string | null | undefined, now: Date): ObjectClaim | null { + if (!detail) return null; + let parsed: { claimToken?: unknown; claimKind?: unknown; claimUntil?: unknown }; + try { + parsed = JSON.parse(detail) as typeof parsed; + } catch { + return null; + } + if (typeof parsed.claimToken !== "string" || typeof parsed.claimUntil !== "string") return null; + if (parsed.claimKind !== "publishing" && parsed.claimKind !== "deleting") return null; + const until = new Date(parsed.claimUntil); + if (Number.isNaN(until.getTime()) || until.getTime() <= now.getTime()) return null; + return { token: parsed.claimToken, kind: parsed.claimKind, until }; +} + +/** + * THE PATH IS HELD BY EXACTLY ONE OPERATION AT A TIME. + * + * A publish and a cleanup both act on one object path, and the cleanup's + * decision to delete is taken BEFORE it deletes. Without a durable claim those + * two facts can interleave fatally: the sweep reads "unreferenced, due", its + * transaction commits having persisted nothing, a publisher then claims the + * path and seals a NEW object at it, and the sweep — still holding a verdict + * it reached before any of that — deletes the object the publisher is about to + * point at. Neither party is wrong; nothing recorded that the other had + * started. + * + * So a claim is written, and it is what the other side collides with: + * - a publisher refuses while a live `deleting` claim exists; + * - a sweep refuses while a live `publishing` claim exists; + * - and the sweep RE-READS its own claim immediately before the delete, so a + * verdict that went stale between the two transactions cannot act. + */ +export function claimsConflict( + events: { detail: string | null }[], + want: ObjectClaimKind, + now: Date, +): ObjectClaimKind | null { + for (const event of events) { + const held = liveClaim(event.detail, now); + if (held && held.kind !== want) return held.kind; + // Two publishers may share a path (content-addressed, identical bytes); + // two deleters may not, and neither may cross. + if (held && held.kind === "deleting" && want === "deleting") return held.kind; + } + return null; +} + +/** + * Reject a row and queue its object for deletion IN ONE TRANSACTION. + * + * The two writes cannot be separate. Delete-then-record loses the object + * whenever the record fails (nothing references the bytes any more, and nothing + * remembers them). Record-then-delete leaves a cleanup event naming a path a + * live row still points at, and the sweep would delete a receipt in use — the + * sweep's own "still referenced" guard papers over that, but only until the row + * is re-pointed. Both in one transaction means the queue entry exists if and + * only if the row is gone. + * + * Returns false when the row's deletion is NOT confirmed. The caller must then + * keep the object and fail retryably: an object with no queue entry and a row + * that still exists is a state we can resume from; the reverse is not. + */ +/** The two writes the reject transaction needs — injectable so it is testable. */ +export interface RejectTxClient { + automationEvent: { create(args: { data: Record; select: { id: true } }): Promise<{ id: string }> }; + receiptIntake: { + deleteMany(args: { where: Record }): Promise<{ count: number }>; + findUnique(args: { where: { id: string } }): Promise | null>; + }; +} + +/** + * The row as it was OBSERVED, which is what the delete is fenced on. + * + * Same shape and same reason as publishFence: a reject and a publish race for + * the same row, and each must lose cleanly rather than act on a row the other + * has already moved. + */ +export interface RejectFence { + id: string; + state: string; + stateReason: string | null; + storagePath: string; + /** The upload lease the caller inspected. A newer one means a newer file. */ + uploadLeaseVersion: number; + /** + * The lease GENERATION the caller inspected, and its expiry. + * + * The version cannot see a REFRESH: `reuseLiveLease` reissues a working + * signed URL over the same path at the same version, so a reject decided + * before that refresh still matched the row and deleted it out from under + * a client whose URL had just been renewed — and queued the path for + * deletion on the stale expiry, which the renewed URL then outlives. See + * leaseFence; this is the same pin, on the delete instead of the publish. + */ + uploadLeaseNonce: string | null; + uploadUrlExpiresAt: Date | null; + /** + * When the OBJECT may be deleted (`cleanupNotBefore()` of the same row). + * Deliberately NOT part of the delete's where clause — the fence is about + * which row we are entitled to remove, this is about when its bytes stop + * being writable by a URL somebody still holds. Null (the default) deletes + * as soon as the caller settles the queued cleanup. + */ + cleanupNotBefore?: Date | null; +} +export interface RejectClient { + $transaction(fn: (tx: RejectTxClient) => Promise): Promise; +} + +export async function rejectRowAndQueueCleanup( + row: RejectFence, + reason: string, + db: RejectClient = prisma as unknown as RejectClient, + /** + * Re-checked against a FRESH read inside the transaction. The caller spent + * a storage round trip deciding this row was unacceptable; anything that + * changed in the meantime (a resumed upload lease, a re-park) has to be + * judged on the row as it is NOW, not as it was when the decision started. + * Return a reason to abort, or null to proceed. + */ + verify: (fresh: Record) => string | null = () => null, +): Promise<{ ok: true; eventId: string } | { ok: false }> { + try { + const eventId = await db.$transaction(async tx => { + const event = await tx.automationEvent.create({ + data: { + kind: STORAGE_CLEANUP_KIND, + status: "pending", + reason: reason.slice(0, 500), + source: "receipt-intake", + detail: JSON.stringify({ + storagePath: row.storagePath, + rowId: row.id, + // The tombstone's whole point: the row is about to be + // gone, so this event is the only thing that will + // still know both the path AND when its signed upload + // URL stops being able to recreate it. + ...(row.cleanupNotBefore + ? { notBefore: row.cleanupNotBefore.toISOString() } + : {}), + }), + }, + select: { id: true }, + }); + // THE FULL FENCE, and EXACTLY ONE ROW. + // + // A reject races a publish for the same row: a concurrent /finalize + // (or the sweeper) can move it to RECEIVED, re-park it under a + // different reason, or seal its object to a new path in the time + // this call spent inspecting the bytes. Deleting by id alone + // destroys that row and, worse, queues ITS object for deletion — + // the published receipt's own bytes. Pinning the observed state, + // reason and storagePath means the loser deletes nothing. + // + // "Already gone" is deliberately NOT treated as success: an absent + // row is a row somebody else accounted for, and queueing its path + // for deletion here is how a live object gets swept. + // RE-READ INSIDE THE TRANSACTION, and let the caller judge it. + const fresh = await tx.receiptIntake.findUnique({ where: { id: row.id } }); + if (!fresh) throw new RejectFenceLost(row.id); + const objection = verify(fresh); + if (objection) throw new RejectFenceLost(`${row.id}: ${objection}`); + + const { count } = await tx.receiptIntake.deleteMany({ + // ONE BUILDER, shared with every other lease-bearing write: + // state, reason, claim, version, generation and expiry. Hand- + // rolling it here is how three sweeper writes came to pin only + // half of it. `storagePath` rides along because a delete is the + // one operation that must also be sure WHICH object it is + // accounting for. + where: { id: row.id, storagePath: row.storagePath, ...leaseFence(row) }, + }); + if (count !== 1) throw new RejectFenceLost(row.id); + return event.id; + }); + return { ok: true, eventId }; + } catch (error) { + // Either way NOTHING is committed: the queue entry rolls back with the + // delete, so there is no cleanup record naming a path a live row still + // points at. + console.error( + "[receipts/intake] reject transaction failed", + row.id, + error instanceof Error ? error.name : "error", + ); + return { ok: false }; + } +} + +/** The delete matched no row: somebody else moved it. Never a partial commit. */ +class RejectFenceLost extends Error { + constructor(rowId: string) { + super(`reject fence lost for ${rowId}`); + this.name = "RejectFenceLost"; + } +} + +/** + * Try the queued deletion now. A failure is not an error for the caller — the + * event stays pending and the worker's sweep retries it. + * + * `notBefore` is the same schedule the event carries. Passing it here is not + * belt-and-braces: this is the OPPORTUNISTIC delete a rejecting request makes + * on its way out, and it runs while the client's signed upload URL is at its + * most likely to still be live. Deleting now would leave the row deleted, the + * object gone, and the URL able to put it straight back with nothing left + * referencing it. Not deleting simply leaves the event pending, which is + * exactly what the sweep is for. + */ +export async function settleQueuedCleanup( + eventId: string, + storagePath: string, + notBefore: Date | null = null, + io: CleanupIo = liveIo, +): Promise { + if (pending(notBefore, io.now())) return false; + try { + await io.remove(storagePath); + } catch (error) { + console.error( + "[receipts/intake] queued delete failed, left pending", + storagePath, + error instanceof Error ? error.name : "error", + ); + return false; + } + // Resolve only AFTER a delete that did not throw, same rule as the sweep. + await io.resolve(eventId); + return true; +} + +/** + * Delete the object. If that fails, record the path so the sweep can retry. + * + * `notBefore` DEFERS the delete entirely rather than attempting it: a live + * signed upload URL for this path can recreate the object after we remove it, + * so the only delete that actually removes the bytes is one taken after the + * URL dies. The queue entry is written immediately either way — the path is + * never left unremembered. + * + * THROWS when the queue entry cannot be written, and that is the whole point. + * + * This used to swallow it and return false, and every caller discarded the + * false AFTER moving the row's pointer — so one transient database failure + * left bytes in a private bucket that no row referenced, no event remembered + * and no sweep would ever look at. Silent and permanent. + * + * Callers that move a pointer must therefore enqueue INSIDE that pointer's + * transaction (`queueObjectCleanup`), so the two commit together or neither + * does. Callers with no transaction to pair with must let the throw reach + * their response, so the client retries instead of being told it worked. + */ +export async function deleteObjectOrRecord( + storagePath: string, + reason: string, + notBefore: Date | null = null, + io: CleanupIo = liveIo, +): Promise { + const scheduled = pending(notBefore, io.now()); + if (scheduled) { + // No catch. See THROWS, above. + await io.record(storagePath, reason, scheduled); + return false; + } + try { + await io.remove(storagePath); + return true; + } catch (error) { + console.error("[receipts/intake] object delete failed", storagePath, error instanceof Error ? error.name : "error"); + // No catch here either — a delete that failed AND a record that failed + // is the one combination that loses an object with nothing left to find + // it, and swallowing it turned that into a silent, permanent leak. The + // caller has to decide, and every caller that moved a pointer has a + // transaction to roll back. + await io.record(storagePath, reason, null); + return false; + } +} + +/** + * Everything the sweep touches, injected so the schedule is a unit test rather + * than a property only a two-hour production wait could demonstrate. The + * default wiring below is the live one. + */ +/** How many queue entries are LOOKED at per delete slot. See retryPendingCleanups. */ +export const CLEANUP_SCAN_FACTOR = 5; + +export interface CleanupSweepDeps { + findPending: (take: number) => Promise<{ id: string; detail: string | null }[]>; + abandon: (eventId: string) => Promise; + /** A SHORT transaction. No storage call may be awaited inside it. */ + inShortTx: (body: (tx: Prisma.TransactionClient) => Promise) => Promise; + remove: (storagePath: string) => Promise; + now: () => Date; +} + +const liveSweepDeps: CleanupSweepDeps = { + findPending: take => prisma.automationEvent.findMany({ + // BOTH statuses. A provisional intent is an object a publish said it + // was about to write and then could not account for; leaving it out of + // the sweep would make the intent a note nobody ever reads. + where: { kind: STORAGE_CLEANUP_KIND, status: { in: CLEANUP_SWEEPABLE_STATUSES } }, + orderBy: { createdAt: "asc" }, + take, + select: { id: true, detail: true }, + }), + abandon: async eventId => { + await prisma.automationEvent.update({ where: { id: eventId }, data: { status: "abandoned" } }); + }, + inShortTx, + // A BARE CALL ONLY. The worker builds its own deps with the invocation's + // deadline (see liveSweepDepsFor): a delete issued with none takes a fresh + // STORAGE_CALL_MAX_MS regardless of how much of the pass is left, which is + // how one came to outlive the claim it was running under. + remove: (storagePath: string) => removeReceiptObject(storagePath, undefined), + now: () => new Date(), +}; + +/** + * The live sweep dependencies, bound to ONE invocation's deadline. + * + * The delete is the only external call this sweep makes, and it must be + * strictly shorter than the claim it runs under -- see DELETE_CLAIM_LEASE_MS. + * Bounding it by the invocation's remaining budget can only make it shorter, + * never longer, so the relation holds however late in the pass it starts. + */ +export function liveSweepDepsFor(deadline: RouteDeadline | undefined): CleanupSweepDeps { + return { + ...liveSweepDeps, + remove: (storagePath: string) => removeReceiptObject(storagePath, deadline), + }; +} + +/** + * Retry the deletions that failed earlier — and run the ones that were never + * attempted because their object was still writable by a live signed URL. + * Bounded per pass, like every other housekeeping step in the worker, and it + * resolves each event it clears so the queue drains instead of growing. + */ +export async function retryPendingCleanups( + limit: number, + shouldStop: () => boolean, + deps: CleanupSweepDeps = liveSweepDeps, +): Promise { + // THE SCAN IS WIDER THAN THE DELETE BUDGET, on purpose. + // + // `limit` bounds STORAGE ROUND TRIPS, which is the expensive part and the + // reason this step is bounded at all. It must not also bound the SELECT: + // the queue now holds entries that are not due yet, and fetching exactly + // `limit` oldest-first would let a burst of scheduled ones occupy every + // slot and starve the genuinely-due entries behind them — the same + // batch-starvation shape the STAGING sweep's own query was fixed for. A + // wider scan is a bigger `take` on one indexed query and nothing else. + // + // It is a widening, not a proof: a due entry sitting behind more than + // CLEANUP_SCAN_FACTOR x limit scheduled ones still waits. That wait is + // bounded rather than indefinite, and by construction — every scheduled + // entry becomes due within the upload lease's own TTL plus the grace, and + // they mature in the order they were created, which is the order this + // query returns them in. + const now = deps.now(); + const queued = await deps.findPending(limit * CLEANUP_SCAN_FACTOR); + + let cleared = 0; + let attempted = 0; + for (const event of queued) { + if (shouldStop() || attempted >= limit) break; + let storagePath: string | null = null; + try { + storagePath = (JSON.parse(event.detail ?? "{}") as { storagePath?: string }).storagePath ?? null; + } catch { + storagePath = null; + } + if (!storagePath) { + // Unparseable detail can never be acted on; close it rather than + // retrying it every five minutes forever. + await deps.abandon(event.id); + continue; + } + // NOT YET. The object's signed upload URL is still live, so deleting it + // now would only open a window for a delayed PUT to recreate it with + // nothing left referencing or remembering the result. Left pending, not + // resolved, and not counted against the batch's storage budget — the + // next pass five minutes later looks again. + if (!cleanupDue(event.detail, now)) continue; + attempted++; + + // ── CLAIM, DELETE, SETTLE — no storage call inside a transaction ── + // + // This used to be one transaction holding the path's advisory lock + // across the Supabase delete. Correct about exclusion, wrong about + // cost: a pooled connection was held for a call the round-16 deadline + // caps at fifteen seconds, so the sweep competed for the pool with + // every finalization running beside it. + // + // PHASE 1 (short tx): decide, and CLAIM. + // + // NEVER delete a path a LIVE row still points at. The recovery + // sequence makes that reachable: an ambiguous upload records a + // cleanup, the row is deleted, the caller retries, and the retry's row + // can end up pointing at the same path — or a seal can publish a + // canonical path an older pending event names. The event is RESOLVED + // rather than retried forever: the object is accounted for, just not + // by us. + // + // And the newest schedule for the path wins, never the one this + // particular event carries. An event records the expiry its author + // OBSERVED, and that author can have been overtaken — a /start refresh + // extends the lease and queues a second cleanup with a LATER deadline; + // a publish in flight holds a provisional lease on this very path. + // Acting on the older event would delete an object something still + // has a live claim on. + // The sweep's own lease over the path: long enough to cover the + // external delete, short enough that a killed pass frees it soon. + const claimUntil = new Date(now.getTime() + OBJECT_CLAIM_LEASE_MS); + const claim = await deps.inShortTx(async tx => { + // THE SAME PER-PATH LOCK THE PUBLISHER TAKES, and first, so the + // two claim transactions serialize and the second reads what the + // first wrote. Everything below -- the reference check, the + // schedule, the claim -- is decided under it. + const taken = await acquireObjectClaim(tx, storagePath, "deleting", claimUntil, now); + if (!taken.ok) { + // A publish holds this path. Its object may not exist yet and + // the pointer that will reference it has not committed, so + // 'nothing references this' is true and irrelevant. + return { verdict: "not-due" as const, siblingIds: [] as string[], token: "" }; + } + const referenced = await tx.receiptIntake.findFirst({ + where: { storagePath }, + select: { id: true }, + }); + if (referenced) { + await tx.automationEvent.update({ + where: { id: event.id }, + data: { status: "resolved", reason: `still referenced by ${referenced.id}` }, + }); + // Hand the path straight back: nothing is going to be deleted, + // and holding it would stall a publisher for the lease's length. + await releaseObjectClaim(tx, storagePath, taken.token); + return { verdict: "referenced" as const, siblingIds: [] as string[], token: "" }; + } + const siblings = await tx.automationEvent.findMany({ + where: { + kind: STORAGE_CLEANUP_KIND, + // Provisional intents included: they name the same object, + // so they carry a schedule this delete must respect — a + // publish's live claim among them — and they must be + // resolved with it rather than left retrying forever + // against bytes that are already gone. + status: { in: CLEANUP_SWEEPABLE_STATUSES }, + detail: { contains: JSON.stringify(storagePath) }, + }, + select: { id: true, detail: true }, + }); + // A PUBLISH HOLDS THIS PATH. Its object may not exist yet, and the + // pointer that will reference it has not committed — so "nothing + // references this" is true and irrelevant. Leave it entirely. + // Exclusion is the claim table's job now (acquireObjectClaim, at the + // top of this transaction). What the siblings still decide is the + // SCHEDULE: the latest notBefore among every event naming this path. + const newest = siblings + .map(sibling => cleanupDueAt(sibling.detail)) + .reduce( + (latest, at) => (at && (!latest || at > latest) ? at : latest), + null, + ); + if (pending(newest, now)) { + await releaseObjectClaim(tx, storagePath, taken.token); + return { verdict: "not-due" as const, siblingIds: [], token: "" }; + } + + // The claim itself was written at the top of this transaction, in + // the one place a claim can live. The event's detail keeps only its + // schedule -- what it is actually for. + return { + verdict: "claimed" as const, + siblingIds: siblings.map(s => s.id), + token: taken.token, + }; + }).catch(error => { + console.error( + "[receipts/intake] cleanup claim failed, left pending", + storagePath, + error instanceof Error ? error.name : "error", + ); + return { verdict: "failed" as const, siblingIds: [] as string[], token: "" }; + }); + + if (claim.verdict !== "claimed") continue; + + // RE-READ THE CLAIM IMMEDIATELY BEFORE THE DELETE. + // + // The verdict above was reached in a transaction that has since + // committed and closed. This confirms, in its own short transaction, + // that the claim written there is still ours and still live — so a + // delete can only proceed on a path nothing else has taken since. + // RENEWED WITH CURRENT TIME, not confirmed against a stale one. + // + // Everything above derives from `now`, captured when the pass started. + // A late item reaches this point seconds later, and the delete that + // follows takes up to STORAGE_CALL_MAX_MS more -- so a claim measured + // from the opening instant can lapse while its own delete is still in + // flight, and a publisher that takes the path in that window has its + // freshly sealed object removed by it. + const stillOurs = await deps.inShortTx(async tx => { + const fresh = await tx.automationEvent.findUnique({ + where: { id: event.id }, + select: { detail: true, status: true }, + }); + if (!fresh || !CLEANUP_SWEEPABLE_STATUSES.includes(fresh.status as CleanupStatus)) return false; + // Against the CLAIM TABLE, which is the one place a claim lives. + const at = deps.now(); + return renewObjectClaim( + tx, + storagePath, + "deleting", + claim.token, + new Date(at.getTime() + DELETE_CLAIM_LEASE_MS), + at, + ); + }).catch(() => false); + if (!stillOurs) continue; + + // PHASE 2: the external delete, with NO transaction open. + // + // A failure leaves every sibling event exactly as it was — still + // pending, still due — so the next pass simply tries again. That is + // the same outcome the old rollback produced, without a connection + // held for the duration. + try { + await deps.remove(storagePath); + } catch (error) { + console.error( + "[receipts/intake] queued cleanup left pending", + storagePath, + error instanceof Error ? error.name : "error", + ); + continue; + } + + const settled = await deps.inShortTx(async tx => { + for (const id of claim.siblingIds.length ? claim.siblingIds : [event.id]) { + await tx.automationEvent.update({ where: { id }, data: { status: "resolved" } }); + } + return true; + }).catch(error => { + // The bytes ARE gone; only the bookkeeping failed. The next pass + // finds the event still pending, removes nothing (the object is + // already absent) and resolves it then. + console.error( + "[receipts/intake] cleanup settle failed after a successful delete", + storagePath, + error instanceof Error ? error.name : "error", + ); + return false; + }); + const verdict = settled ? "deleted" : "failed"; + if (verdict === "deleted") cleared++; + } + return cleared; +} diff --git a/src/lib/receipt-intake/stored-object.ts b/src/lib/receipt-intake/stored-object.ts new file mode 100644 index 000000000..bb2aa7f83 --- /dev/null +++ b/src/lib/receipt-intake/stored-object.ts @@ -0,0 +1,588 @@ +/** + * The one place a STAGING row's stored object is validated and turned into row + * metadata. + * + * Two callers publish a STAGING row — /intake/{id}/finalize (the client says it + * has finished uploading) and the worker's stale-STAGING sweep (nobody ever + * came back, but the object is there). They MUST agree: a sweep that published + * on "the object exists" alone would wave through a 40 MB video, a .exe, or a + * truncated upload that /finalize would have rejected — and those rows then go + * to Gemini and, if they read at all, to QuickBooks. + * + * Everything here is derived from the BYTES IN STORAGE. The client uploaded + * straight to Supabase, so nothing it declared about the file is evidence. + */ +import { createHash } from "node:crypto"; +import type { Prisma } from "@prisma/client"; +import type { DocBytesResult } from "@/lib/secure-storage"; +import { downloadReceiptObject, receiptObjectSize, type BucketLister, type SizeResult } from "./bucket"; +import type { RouteDeadline } from "@/lib/quickbooks"; +import { EXT_BY_MIME, sniffMime } from "./file-type"; +import { MAX_STORED_BYTES } from "./intake-core"; + +/** + * Where a VERIFIED object lives, keyed by its own content hash. + * + * The upload path is writable by whoever holds the signed URL, and that URL is + * `upsert: true` so a resumed /start can replace its own partial upload. Both + * are necessary and together they mean the upload path can change AFTER we + * verified it. Sealing copies the bytes somewhere the client was never given a + * URL for, and names it after the sha — so the path itself asserts the content, + * and re-verifying on download is a comparison against a value that cannot have + * been rewritten in place. + * + * THE UPLOAD LEASE VERSION IS IN THE PATH, and it is not decoration. A path + * that is a function of the row and the bytes alone is reused by every later + * attempt on the same row — including one that follows a rejection, and a + * rejection is what QUEUES A DELETION of that exact path. A re-armed /start + * bumps the lease, so this makes a re-seal target a path no outstanding cleanup + * event can be naming. (Belt and braces: claimObjectPath also cancels + * any pending cleanup for the path it is about to fill. Either alone closes the + * reuse; both is cheap.) + */ +export function canonicalStoragePath(id: string, leaseVersion: number, sha256: string, mimeType: string): string { + const ext = EXT_BY_MIME[mimeType] ?? "bin"; + return `receipts/${id}/v${leaseVersion}/${sha256}.${ext}`; +} + +/** + * Read bytes and REFUSE them if they are not what the row recorded. + * + * Every consumer of a stored receipt (the reader, the booker) goes through + * this. A hash stored at finalize is worthless if nothing ever checks it again. + */ +export type VerifiedBytes = + | { ok: true; bytes: Buffer } + | { ok: false; kind: "missing" | "transient" | "sha-mismatch"; message?: string }; + +/** + * SEAL AND PUBLISH — the one operation that moves a row out of STAGING. + * + * Shared by /intake/{id}/finalize and the worker's stale-STAGING sweep so the + * two cannot diverge: the sweeper used to publish while the row still pointed + * at the UPLOAD path, which is writable by anyone holding the signed URL, so a + * swept row's "verified" bytes stayed replaceable afterwards. + * + * Order is the whole point: + * 1. copy the verified bytes to the canonical (content-addressed) path + * 2. COMMIT the row pointer, fenced on state and claim + * 3. only then delete the upload object, best-effort + * + * A crash between 1 and 2 leaves both objects and a STAGING row: the retry + * finds the canonical copy already there, re-uploads it as a no-op, and + * commits. A failure at 3 is an orphan on the cleanup queue, not a lost + * receipt. + * + * AND STEPS 1 AND 2 ARE ONE CRITICAL SECTION. The gap between them is a window + * in which the bytes exist and nothing in the database references them — which + * is exactly the shape the storage-cleanup sweep tests for before it deletes. + * A sweep landing in that window deleted the object this call had just sealed, + * and the commit then published a row pointing at nothing. `withObjectLock` + * holds the canonical path's advisory lock across both, so the sweep either + * runs entirely before the seal (and the seal writes the bytes again) or + * entirely after the commit (and sees the reference). See + * claimObjectPath in storage-cleanup.ts. + * + * Nothing slow goes inside that lock: no read, no QBO call, no attachment — + * one storage copy and one fenced UPDATE. + */ +export interface PublishOutcome { + published: boolean; + canonicalPath: string; +} + +/** + * The database handle the object lock hands to the critical section. Only the + * row pointer (and the loser's orphan bookkeeping) may be written with it — + * every other writer of this path is blocked for as long as it is open. + */ +export type PublishTx = Prisma.TransactionClient; + +export interface SealPublishDeps { + /** + * ONE SHORT TRANSACTION, holding NO storage call. + * + * This replaces `withObjectLock` — a transaction-scoped advisory lock that + * stayed open across the Supabase seal. That made a pooled connection + * hostage to a storage round trip which the round-16 deadline caps at + * FIFTEEN SECONDS, so a handful of concurrent finalizations exhausted the + * five-connection pool and later requests could not reach the database at + * all — including to release anything. The lock made the POOL the + * contended resource instead of the object. + * + * Nothing external may be awaited inside the body. + */ + inShortTx: (body: (tx: PublishTx) => Promise) => Promise; + /** + * PHASE A. In its own short transaction, and BEFORE anything is written to + * storage: cancel any queued deletion of this path and record a + * provisional INTENT for it, carrying a lease. Returns the intent's id. + * + * THE LEASE IS WHAT REPLACES THE ADVISORY LOCK. While it is live the + * sweeper leaves the path alone, so the object phase B is about to write + * cannot be collected between that write and the pointer commit — the + * window the lock existed to close, now held open without a transaction + * and therefore without a connection. + * + * A throw means NOTHING is sealed: writing an object we could not first + * promise to clean up is the leak itself. + */ + claimCanonicalPath: (canonicalPath: string) => Promise; + /** PHASE B. The external write. Called with no transaction open. */ + seal: ( + uploadPath: string, + canonicalPath: string, + bytes: Buffer, + contentType: string, + deadline: RouteDeadline | undefined, + ) => Promise; + /** PHASE C. The fenced CAS. Returns the number of rows actually moved. */ + commit: ( + tx: PublishTx, + canonicalPath: string, + check: { mimeType: string; fileSize: number; fileSha256: string }, + ) => Promise; + /** + * Enqueue the upload object's cleanup INSIDE the settle transaction. The + * queue entry is the only thing that remembers that object once the row + * stops pointing at it, so it commits with the pointer or not at all. + */ + queueUploadCleanup: (tx: PublishTx, uploadPath: string) => Promise; + /** + * Cancel the phase-A intent, in the SAME transaction as the pointer + * commit: the object is referenced from that instant. + */ + resolveCanonicalIntent: (tx: PublishTx, eventId: string) => Promise; + /** + * Try the queued upload deletion now, AFTER the pointer is committed and + * with no transaction open. Best-effort by design: the event is durable, + * so a failure here is just work the sweep picks up. + */ + settleUploadCleanup: (eventId: string, uploadPath: string) => Promise; +} + +export async function sealAndPublish( + uploadPath: string, + rowId: string, + /** The lease the bytes arrived on. It is part of the canonical path. */ + leaseVersion: number, + check: { mimeType: string; fileSize: number; fileSha256: string; bytes: Buffer }, + deps: SealPublishDeps, + /** + * The route's ONE deadline, handed to the seal. Every storage call this + * invocation makes draws on the same shrinking budget. + * + * REQUIRED, so the compiler enumerates the callers. An optional one is + * silently omittable, and every caller that omitted it handed its + * storage call a fresh fifteen seconds inside an invocation that had + * already spent most of its life. + */ + deadline: RouteDeadline | undefined, +): Promise { + const canonicalPath = canonicalStoragePath(rowId, leaseVersion, check.fileSha256, check.mimeType); + + // ── THE THREE-PHASE PUBLISH ──────────────────────────────────────────── + // + // No Supabase call happens with a database transaction open. The advisory + // lock this replaces held one across the seal, so a storage round trip + // (capped at fifteen seconds) held a pooled connection for its whole + // duration and a handful of concurrent finalizations exhausted the pool. + // + // A. CLAIM — one short tx: cancel any queued deletion of the canonical + // path, and record a provisional INTENT for it carrying a + // lease. The lease is what replaces the lock: while it is + // live the sweeper leaves the path alone, so the object we + // are about to write cannot be collected mid-publish. + // B. SEAL — the external write, with NO transaction open. + // C. SETTLE — one short tx: the fenced CAS, and on success the upload + // cleanup and the intent's cancellation in the same commit. + // + // A publish that dies between B and C leaves a live intent that lapses; + // the sweeper then reclaims it, rechecks live references, and either + // resolves it (somebody published this path) or deletes the orphan. + // + // MUTUAL EXCLUSION IS NOT LOST BY DROPPING THE LOCK. The canonical path is + // content-addressed — rowId, lease version, sha and mime — so two + // publishers racing on it are, necessarily, writing IDENTICAL bytes to it. + // The seal is an upsert, so doing it twice is a no-op by construction. Only + // the pointer needs serializing, and the CAS in phase C already does that. + let intentId: string; + try { + intentId = await deps.claimCanonicalPath(canonicalPath); + } catch (error) { + // Writing an object we could not first promise to clean up IS the leak. + console.error( + "[receipts/intake] could not claim the canonical path; nothing sealed", + rowId, + error instanceof Error ? error.name : "error", + ); + return null; + } + + // PHASE B: no transaction is open here. This is the whole point. + const sealed = await deps.seal(uploadPath, canonicalPath, check.bytes, check.mimeType, deadline); + if (!sealed) return null; + + const moved = await deps.inShortTx(async tx => { + const count = await deps.commit(tx, canonicalPath, check); + if (count > 0) { + // THE POINTER MOVED, so the upload object is orphaned from this + // instant — and the record of that has to commit WITH the move, + // not after it. A throw takes the pointer transition down with it + // and the row stays on the upload path, still reachable. + const eventId = uploadPath === canonicalPath + ? null + : await deps.queueUploadCleanup(tx, uploadPath); + // ...and the canonical object is REFERENCED from this instant, so + // its intent is cancelled in the same transaction that references + // it. A throw rolls both back and the intent correctly survives. + await deps.resolveCanonicalIntent(tx, intentId); + return { count, eventId }; + } + + // LOST THE CAS — the lease was re-claimed, or another publisher moved + // the row first. DO NOT TOUCH THE DATABASE FURTHER and do not delete + // anything: the winner may be pointing at this very object, and the + // intent recorded in phase A already accounts for it either way. The + // sweeper resolves it by re-checking live references once the lease + // lapses, which is the one place that question can be asked safely. + // + // This is the case the old code answered with a storage DELETE from + // inside the transaction. + return { count, eventId: null }; + }).catch(error => { + + // A lock we could not take, or a transaction that could not commit. + // NOTHING was published, and the same `null` the seal failure returns + // is the honest answer: a retryable "come back", never a verdict. + console.error( + "[receipts/intake] publish critical section failed", + rowId, + error instanceof Error ? error.name : "error", + ); + return null; + }); + if (moved === null) return null; + + if (moved.count > 0) { + // Only once the row points at the sealed copy is the upload object + // safe to remove — and only if we are the one who moved the row. A + // publisher that lost the CAS must not delete an object the winner + // may still be using. Outside the lock deliberately: it is a + // best-effort delete of a DIFFERENT path, and it must not hold a lock + // every other publisher of this row is waiting on. + // + // Best-effort is SAFE here now, and only because the queue entry + // committed with the pointer above: whatever happens to this call, the + // path is remembered and the sweep will get to it. + if (moved.eventId) { + await deps.settleUploadCleanup(moved.eventId, uploadPath).catch(() => undefined); + } + return { published: true, canonicalPath }; + } + return { published: false, canonicalPath }; +} + +export async function downloadVerified( + storagePath: string, + expectedSha256: string, + /** The invocation's ONE deadline. REQUIRED — see verifyStoredCopy. */ + deadline: RouteDeadline | undefined, + download: (storagePath: string, deadline: RouteDeadline | undefined) => Promise = downloadReceiptObject, +): Promise { + const result = await download(storagePath, deadline); + if (!result.ok) { + return result.kind === "not-found" + ? { ok: false, kind: "missing" } + : { ok: false, kind: "transient", message: result.message }; + } + // An empty expectation means a legacy row written before sealing existed; + // there is nothing to compare against, so pass the bytes through rather + // than refuse a receipt for a reason that is our fault. + if (!expectedSha256) return { ok: true, bytes: result.bytes }; + + const actual = createHash("sha256").update(result.bytes).digest("hex"); + if (actual !== expectedSha256) { + return { ok: false, kind: "sha-mismatch", message: `expected ${expectedSha256}, stored ${actual}` }; + } + return { ok: true, bytes: result.bytes }; +} + +/** + * A DECLARED HASH IS A CLAIM ABOUT WHICH DOCUMENT THIS CALL IS ABOUT — and it + * has to be answered on every path that can say "we have it", not only on the + * one that publishes. + * + * /finalize checked `sha256` against the STORED BYTES on the publish path and + * nowhere else, so a call against an already-settled row (RECEIVED, READ, + * BOOKED) verified the object against the ROW's recorded hash, ignored the + * different hash the request carried, and returned 200 alreadyFinalized. A + * forwarder that sent the wrong row id — a stale mapping, a reused id, a + * mis-parsed response — was told we held ITS document while we held somebody + * else's, and it deletes its only copy on that answer. + * + * Both halves must be present for this to be an answer: with no declared hash + * the caller asserted nothing, and an empty `fileSha256` is a row that was + * written before sealing existed (see downloadVerified) and has no verified + * identity to compare against. Neither is evidence of a conflict, and refusing + * on either would break honest callers. + */ +export function declaredShaConflict(recordedSha: string | null, declaredSha: string | null): boolean { + if (!declaredSha || !recordedSha) return false; + return recordedSha.toLowerCase() !== declaredSha.toLowerCase(); +} + +/** + * "WE ALREADY HAVE IT" — THE ONE RULE BOTH REPLAY PATHS ANSWER IT WITH. + * + * `POST /api/receipts/intake` (a forwarder re-sending the same bytes) and + * `POST /api/receipts/intake/{id}/finalize` (a client retrying a finalize) both + * end in a 2xx that tells the sender we hold its document — and the forwarders + * delete their only copy on that answer. + * + * They used to decide it from PRESENCE alone: one metadata call saying something + * sits at the path. That authorised the delete on the strength of bytes nobody + * had looked at since they were sealed, so an object replaced or corrupted after + * publication (an upsert URL reused, a restore that put back a different + * version, a storage-side fault) was laundered into "we have your receipt" and + * the last good copy went with it. The row's `fileSha256` is the only hash this + * system has ever verified; the stored bytes must still hash to it. + * + * Cheap probe first, so the common orphan case never pays for a download. + * `content-mismatch` is deliberately its own answer: it is NOT retryable (the + * sender resending changes nothing) and it must never be healed here — the row + * is left exactly as it is for the worker's `content-changed` park and the + * sweeper to act on. + */ +export type StoredCopyCheck = + | { ok: true } + | { ok: false; kind: "missing" | "transient" | "content-mismatch"; message?: string }; + +export async function verifyStoredCopy( + storagePath: string, + /** What the row was published with. Empty means a legacy row — see downloadVerified. */ + fileSha256: string, + /** + * The invocation's ONE deadline. REQUIRED: this function makes up to two + * storage calls, and both used to be issued with none at all -- a fresh + * fifteen seconds each, inside a handler the platform kills at thirty. + */ + deadline: RouteDeadline | undefined, + sizeOf: (storagePath: string, lister: BucketLister | null | undefined, deadline: RouteDeadline | undefined) => Promise = receiptObjectSize, + download: (storagePath: string, deadline: RouteDeadline | undefined) => Promise = downloadReceiptObject, +): Promise { + const present = await sizeOf(storagePath, null, deadline); + if (!present.ok) { + return present.kind === "missing" + ? { ok: false, kind: "missing" } + : { ok: false, kind: "transient", message: present.message ?? "size-unavailable" }; + } + const verified = await downloadVerified(storagePath, fileSha256, deadline, download); + if (verified.ok) return { ok: true }; + // It was there a moment ago, so a `missing` here is a race, not a verdict — + // and either way it is never a 2xx. + return verified.kind === "sha-mismatch" + ? { ok: false, kind: "content-mismatch", message: verified.message } + : { ok: false, kind: verified.kind, message: verified.message }; +} + +export type StoredObjectCheck = + /** + * Valid: these are the values the row must be published with, plus the + * exact bytes that produced them — so the sealed copy is provably the + * content that was verified, not a second download that could differ. + */ + | { ok: true; mimeType: string; fileSize: number; fileSha256: string; bytes: Buffer } + /** The object is not there. Terminal for the sweep; retryable for a client. */ + | { ok: false; kind: "missing" } + /** Storage could not answer. Never a verdict — come back later. */ + | { ok: false; kind: "transient"; message: string } + /** The object exists and is NOT acceptable. The row and object must go. */ + | { ok: false; kind: "rejected"; reason: string }; + +export async function inspectStoredObject( + storagePath: string, + /** + * What the row recorded at /start. Used ONLY for text/plain, which has no + * magic bytes — the same concession the single-shot path makes. Every + * format that CAN be identified is identified from the bytes. + */ + declaredMime: string, + /** The invocation's ONE deadline. REQUIRED -- see verifyStoredCopy. */ + deadline: RouteDeadline | undefined, + download: (storagePath: string, deadline: RouteDeadline | undefined) => Promise = downloadReceiptObject, + /** Metadata-only size lookup; injected so the "no body read" test is provable. */ + sizeOf: (storagePath: string, lister: BucketLister | null | undefined, deadline: RouteDeadline | undefined) => Promise = receiptObjectSize, +): Promise { + // SIZE FIRST, FROM METADATA — before a single byte is read. + // + // The signed upload URL bypasses this server, so nothing has seen this + // object yet. Downloading it to discover it is 400 MB is how one upload + // takes the worker's whole invocation (and its memory) with it. `list` + // returns the metadata row in one small request whatever the object's size. + // + // AN UNKNOWN SIZE IS TRANSIENT, not permission to proceed. It used to mean + // "carry on and let the byte-length check catch it" — which is the download + // this call exists to avoid, taken on exactly the objects we know least + // about (a storage hiccup, a missing client, an API with no metadata). The + // sweep and the client both retry a transient answer; neither can be hurt + // by waiting, and both can be hurt by a 400 MB read. + const declared = await sizeOf(storagePath, null, deadline); + if (!declared.ok) { + return declared.kind === "missing" + ? { ok: false, kind: "missing" } + : { ok: false, kind: "transient", message: declared.message ?? "size-unavailable" }; + } + if (declared.size > MAX_STORED_BYTES) { + return { ok: false, kind: "rejected", reason: `file-too-large:${declared.size}` }; + } + + const result = await download(storagePath, deadline); + if (!result.ok) { + return result.kind === "not-found" + ? { ok: false, kind: "missing" } + : { ok: false, kind: "transient", message: result.message }; + } + + const bytes = result.bytes; + if (bytes.length === 0) return { ok: false, kind: "rejected", reason: "empty-file" }; + // Enforced on the OBJECT, because the signed upload URL bypassed every + // check this server could otherwise have made. + if (bytes.length > MAX_STORED_BYTES) { + return { ok: false, kind: "rejected", reason: `file-too-large:${bytes.length}` }; + } + + // Magic bytes, exactly like the single-shot path. A declared mime is a + // claim; this is the answer. + const mimeType = sniffMime(bytes, declaredMime); + if (!mimeType) return { ok: false, kind: "rejected", reason: "unsupported-file-type" }; + + return { + ok: true, + mimeType, + fileSize: bytes.length, + fileSha256: createHash("sha256").update(bytes).digest("hex"), + bytes, + }; +} + +/** + * The only two parked reasons a later, correct upload may recover from. + * + * "Any NEEDS_REVIEW row" is far too broad: a row parked for a vendor mismatch, + * a zero total or a QBO fault would be dragged back to RECEIVED and re-read, + * discarding a decision a human already made about it — and, past BOOKING, that + * re-read is a second Purchase waiting to happen. + */ +export const RECOVERABLE_PARK_REASONS = ["file-missing", "sha-mismatch"]; + +export interface ObservedRow { + state: string; + stateReason: string | null; + /** + * The upload lease this decision was made against. A resumed or re-armed + * /start bumps it, so a sweep that decided on v1 writes nothing once the + * client is on v2 — the row it judged does not exist any more. + */ + uploadLeaseVersion: number; + /** + * THE LEASE GENERATION — and the only thing that can see a REFRESH. + * + * `reuseLiveLease` hands a retrying client a brand-new signed URL over the + * SAME path and the SAME version: nothing else about the row moves. So a + * fence built from state/reason/version/path matches just as well after + * that refresh as before it, and an in-flight finalizer that read the row + * BEFORE it could still publish — or delete a rejected row — on the + * strength of a lease somebody has already replaced. This column is + * rewritten on every issue and every adoption, so pinning it is what makes + * "nobody re-leased this row since I read it" checkable at all. + * + * Nullable: rows created before the column existed, and the single-shot + * inline path, carry null — and null pins just as well as a value. + */ + uploadLeaseNonce: string | null; + /** Pinned beside the nonce: see leaseFence. */ + uploadUrlExpiresAt: Date | null; +} + +/** What a finalize may do with the row it just read. */ +export type FinalizeDisposition = "publish" | "not-recoverable" | "settled"; + +/** + * Reads only the state and the reason, and says so: this is a question about + * what a finalize may DO, not about which lease it observed. + */ +export function finalizeDisposition(row: Pick): FinalizeDisposition { + if (row.state === "STAGING") return "publish"; + if (row.state === "NEEDS_REVIEW") { + return RECOVERABLE_PARK_REASONS.includes(row.stateReason ?? "") ? "publish" : "not-recoverable"; + } + return "settled"; +} + +/** + * The CAS a publish must carry: the EXACT state and reason that were observed, + * and an unclaimed row. + * + * `state: { in: [...] }` was not enough. Inspecting the object and sealing it + * takes seconds, and in that window the reason can change — a row parked + * `file-missing` can be re-parked `vendor-mismatch`, or the worker can claim it. + * A publish fenced only on the state SET would then reset a reason it never + * looked at back to RECEIVED, discarding the newer decision and republishing a + * row somebody else now owns. Pinning the reason makes that update match zero + * rows, which is a 409 the client can retry rather than a silent overwrite. + */ +export function publishFence(row: ObservedRow): { + state: string; + stateReason: string | null; + claimToken: null; + uploadLeaseVersion: number; +} { + return { + state: row.state, + stateReason: row.stateReason, + claimToken: null, + uploadLeaseVersion: row.uploadLeaseVersion, + }; +} + +/** + * THE FENCE EVERY DESTRUCTIVE FINALIZER MUST CARRY: publishFence PLUS the + * lease generation it observed. + * + * publishFence alone cannot see a lease REFRESH. `reuseLiveLease` reissues a + * signed URL over the same path and the same version — state, reason, claim + * and version are all untouched — so a finalizer that read the row before the + * refresh still matches, and: + * + * - a PUBLISH commits on bytes the client has already been invited to + * replace, and schedules the upload object's cleanup against the OLD + * expiry. The refreshed URL then outlives that schedule, and a later valid + * PUT recreates an object nothing references. + * - a REJECT deletes the row out from under a client that holds a working + * URL, and queues the path for deletion on the same stale schedule. + * + * Pinning the nonce turns both into a lost CAS, which every caller already + * answers as a retryable 409. The expiry rides along because it is free and + * rules out an adopter independently — the same belt-and-braces + * discardUnresumedLease uses. + * + * `reuseLiveLease` deliberately does NOT use this: two honest /start retries + * may legitimately adopt the same live lease, and failing the second would + * break the idempotency that module exists to provide. + */ +export function leaseFence(row: ObservedRow): ReturnType & { + uploadLeaseNonce: string | null; + uploadUrlExpiresAt: Date | null; +} { + return { + ...publishFence(row), + uploadLeaseNonce: row.uploadLeaseNonce, + uploadUrlExpiresAt: row.uploadUrlExpiresAt, + }; +} + +/** Where the bytes for one upload lease live. The version is IN the path. */ +export function uploadPathFor(rowId: string, leaseVersion: number, ext: string): string { + return `receipts/intake/${rowId}.v${leaseVersion}.${ext}`; +} diff --git a/src/lib/receipt-intake/upload-lease.ts b/src/lib/receipt-intake/upload-lease.ts new file mode 100644 index 000000000..2df77b4e4 --- /dev/null +++ b/src/lib/receipt-intake/upload-lease.ts @@ -0,0 +1,483 @@ +/** + * ONE LEASE-REUSE RULE, FOR EVERY RESUMABLE STATE. + * + * A /start retry against a row whose upload lease has NOT expired must be + * idempotent: the SAME path, the SAME lease version, nothing deleted. Every + * other /start branch is destructive by design — it bumps the version, repaths + * the row, and drops the previous object — and running that while a signed URL + * is still live invalidates the ORIGINAL caller's URL and deletes the object it + * is about to PUT its bytes to. Two /start calls for one sourceRef (a network + * retry, a double-tap, a forwarder's own retry policy) are exactly that race. + * + * `createSignedUploadUrl` does not revoke a previously issued token when called + * again for the SAME path, so an unexpired lease can safely be handed a fresh + * signed URL over its existing object identity. + * + * An earlier round fixed this for STAGING rows ONLY, which left the recoverable + * NEEDS_REVIEW re-arm (file-missing / sha-mismatch) destructive on every retry: + * a parked row that a forwarder retried twice still had its live path deleted + * out from under the first attempt. The rule does not depend on the state, so + * neither does this module — both /start branches go through it. + */ +import { randomUUID } from "node:crypto"; +import { leaseFence, uploadPathFor, type ObservedRow } from "./stored-object"; + +export interface LeaseRow extends ObservedRow { + id: string; + storagePath: string; + /** Null on rows that never had a signed URL (the single-shot path). */ + uploadUrlExpiresAt: Date | null; + /** + * The hash the LIVE lease was issued for. Part of the lease's identity: + * see reuseLiveLease. Empty on a row that never announced one. + */ + expectedSha256?: string | null; +} + +export interface SignedUpload { + uploadUrl: string; + token: string; + storagePath: string; +} + +/** + * What /start hands back, and what /finalize demands to see again. + * + * `uploadLease` is the row's `uploadLeaseNonce` — an opaque, single-use-ish + * generation stamp. It is returned so a finalizer can PROVE which lease its + * signed URL was issued under. Without it /finalize read the row's CURRENT + * nonce, so a delayed finalizer silently adopted whatever lease had been + * issued since: two /start calls hand out URLs for the SAME path, and the + * first client's stale finalize could then inspect a half-written object and + * reject the row out from under the second client's perfectly live URL. + * + * Opaque by contract. It is a random UUID with no meaning outside the CAS — + * not a capability (the signed URL is), and it grants nothing on its own. + */ +export interface IssuedLease extends SignedUpload { + uploadLease: string; +} + +export interface LeaseClient { + updateMany(args: { + where: Record; + data: Record; + }): Promise<{ count: number }>; +} + +export interface LeaseDeps { + db: LeaseClient; + /** + * `opts.upsert` is passed through to the signer. Only THIS module ever + * asks for it (see below), so every other issuer gets a create-only token. + */ + sign: (storagePath: string, opts: { upsert: boolean }) => Promise; + /** + * Re-read the row. The adoption CAS is exclusive now, so a loser has to + * see what actually won; and every issued lease is re-checked after the + * signing round trip. Both need a fresh read, so it is a dependency + * rather than something the caller does around this rule. + */ + reload: (id: string) => Promise; + /** When a freshly issued URL stops working. */ + expiresAt: () => Date; + now?: () => number; + /** + * The adoption generation written on every lease issue or extension. A + * fresh, unguessable value each call — see `newLeaseNonce`. + */ + nonce?: () => string; +} + +/** + * The value `uploadLeaseNonce` carries, and the reason it is random rather than + * a counter. + * + * A counter would have to be READ before it could be incremented, which is one + * more thing for two concurrent adopters to agree about; a random value needs + * no read at all and still gives the discard CAS the only property it wants — + * "nobody else has written this column since I did". Uniqueness is the whole + * contract; ordering is not. + */ +export function newLeaseNonce(): string { + return randomUUID(); +} + +/** + * A LIVE LEASE'S IDENTITY IS IMMUTABLE FOR ITS LIFETIME. + * + * Path, declared MIME (which the path's extension encodes) and announced + * sha256 are fixed when the lease is issued. A retry that agrees with all + * three may extend it; one that disagrees may not have it, because both + * outcomes of allowing it are broken: + * + * - a DIFFERENT extension made liveLeasePath refuse the path, and the + * caller then fell through to the destructive resume: a new version, a + * new path and a new generation, while the first caller's signed URL was + * still live and still pointed at the object that was about to be + * orphaned. + * - a DIFFERENT expectedSha256 was written straight through the extension, + * keeping the same generation, so two callers held ONE lease for two + * different documents and only whichever hash landed last could + * finalize. + * + * So it is a refusal, and it carries the live lease's expiry: the caller can + * wait for it to lapse (after which the destructive branch is safe, because + * nothing live relies on it any more) or start a separate intake. + */ +export type LeaseIdentityField = "mime" | "sha256"; + +export type LeaseReuse = + | { kind: "signed"; signed: IssuedLease } + | { kind: "storage-unavailable" } + | { kind: "identity-conflict"; field: LeaseIdentityField; expiresAt: Date } + | { kind: "conflict" }; + +/** + * Is there a live lease this request may reuse, and at what path? + * + * Null means "take a new lease" and covers three cases: + * - no lease was ever issued (`uploadUrlExpiresAt` null) + * - the lease expired — fair game to invalidate, nothing live relies on it + * - the row's path is not the one THIS request's extension names. The path is + * derived from (id, leaseVersion, ext), so a caller that changed its + * declared type has to take a new lease; reusing would leave the row + * pointing at an object whose name disagrees with its type. + */ +export function liveLeasePath(row: LeaseRow, ext: string, now: number = Date.now()): string | null { + if (!hasLiveLease(row, now)) return null; + const named = uploadPathFor(row.id, row.uploadLeaseVersion, ext); + return named === row.storagePath ? named : null; +} + +/** + * Is there a lease at all, whatever this request's extension says? + * + * liveLeasePath collapses two very different answers into null -- "nothing + * live here, go take a new lease" and "there IS a live lease, but you are + * asking about a different file type". The second is a refusal, not an + * invitation to repath: the previous caller's URL still works. + */ +export function hasLiveLease(row: LeaseRow, now: number = Date.now()): boolean { + return !!row.uploadUrlExpiresAt && row.uploadUrlExpiresAt.getTime() > now; +} + +/** + * Does this request agree with the live lease's announced hash? + * + * An empty stored value is adopted rather than compared -- a legacy row, or + * one whose lease predates the field, has announced nothing to disagree with. + */ +export function sha256Agrees(row: LeaseRow, expectedSha256: string): boolean { + const held = (row.expectedSha256 ?? "").toLowerCase(); + return !held || held === expectedSha256.toLowerCase(); +} + +/** + * IS THIS THE SAME LEASE THE CALLER DECIDED ABOUT — only refreshed? + * + * A lost CAS may be re-tried, but ONLY against a row that is still the one the + * caller looked at. /start decides whether a row is recoverable, and whether + * its hash proves identity, from the row it read; a retry that silently + * re-aimed at whatever is there now would re-arm a row that has since been + * re-parked under a reason nobody here examined, or repathed to a new lease + * whose object this request knows nothing about. + * + * So exactly two columns may move: `uploadLeaseNonce` and `uploadUrlExpiresAt` + * — which is precisely the footprint of ANOTHER ADOPTER extending the same + * live lease, the one case two honest retries are supposed to converge on. + * Everything else is a conflict. + */ +function sameLease(decidedOn: LeaseRow, fresh: LeaseRow): boolean { + return fresh.id === decidedOn.id + && fresh.state === decidedOn.state + && fresh.stateReason === decidedOn.stateReason + && fresh.storagePath === decidedOn.storagePath + && fresh.uploadLeaseVersion === decidedOn.uploadLeaseVersion; +} +/** + * THE EXTENDED EXPIRY, GUARANTEED DIFFERENT FROM THE ONE IT REPLACES. + * + * `discardUnresumedLease` proves "nobody adopted the row I created" by pinning + * the exact expiry it wrote. An adoption that extends a live lease over the + * same path, at the same version and under the same generation, moves NOTHING + * ELSE — so if it can also write the same instant, the discard's witness sees + * nothing and deletes a row somebody is uploading to. Production computes both + * expiries as "now + 2h": milliseconds apart, or on two hosts whose clocks are + * merely close, they collide. + * + * So the adoption forces the difference instead of hoping for it: at least one + * millisecond past what was there. It also never moves the expiry BACKWARDS, + * which a skewed clock would otherwise do — shortening a lease whose holder is + * still using a freshly signed URL is how the sweeper reclaims a live row. + */ +export function extendedExpiry(observed: Date | null, fresh: Date): Date { + if (!observed) return fresh; + return fresh.getTime() > observed.getTime() ? fresh : new Date(observed.getTime() + 1); +} + +/** + * How many times an adoption may lose its CAS and re-read before giving up. + * Each loss means somebody else moved the row, so each retry starts from a + * strictly newer observation; the bound is here to stop a pathological + * hot-spot spinning inside one request, not because progress is in doubt. + */ +export const MAX_LEASE_ADOPTION_ATTEMPTS = 4; + +/** + * Extend a live lease and reissue a URL for its EXISTING path. + * + * `rearm` carries the identity writes a recovery needs (a corrected + * `expectedSha256`, a cleared `fileSha256`), because a recoverable park may + * legitimately come back with a different hash — they simply land on the same + * path and the same lease version instead of on a new one. + * + * THE NONCE NAMES THE LEASE, NOT THE REQUEST — and that is the round-19 fix. + * + * This function used to mint a fresh `uploadLeaseNonce` on every adoption and + * deliberately leave it OUT of its own CAS, so two concurrent /start retries + * both matched, both wrote, and both returned a 200 carrying their own nonce. + * Only the last write survived. /finalize demands the generation its URL was + * issued under, so the earlier caller's perfectly good signed URL was answered + * `409 lease-stale` for a lease it had been handed seconds before: an endpoint + * whose entire purpose is idempotent retries was issuing responses that could + * never be finalized. + * + * An extension is not a new lease. Same path, same version, same object + * identity — so it keeps the generation it adopted, and both retries hand back + * the SAME `uploadLease`. Both are finalizable, which is the property that was + * missing. A genuinely new lease (the create, the resume repath, the re-arm + * repath) still mints one, because those DO change the path or the version. + * + * The full `leaseFence` is now the CAS, nonce and expiry included, so exactly + * one adopter writes per observed generation. The loser is not a conflict: it + * re-reads and tries again against what it now sees, which is how two honest + * retries both end up holding the winner's lease rather than one of them being + * told 409. A lost CAS is only reported as `conflict` when the row has moved + * somewhere this rule cannot follow (repathed, published, parked) or the + * attempts run out. + * + * AND THE RESULT IS REVALIDATED AFTER SIGNING. The CAS proves the lease was + * ours when we wrote it; signing is a network round trip, and a concurrent + * resume can bump the version and repath the row while it is in flight. A + * nonce returned without that second look is one the row may already have + * moved past — the same un-finalizable 200, arrived at from the other side. + */ +export async function reuseLiveLease( + row: LeaseRow, + ext: string, + deps: LeaseDeps, + rearm: Record = {}, + /** + * The hash THIS request announced. A live lease's is immutable, so a + * disagreement is a refusal rather than an overwrite -- see LeaseReuse. + */ + expectedSha256 = "", +): Promise { + const now = () => (deps.now ? deps.now() : Date.now()); + let observed: LeaseRow = row; + + for (let attempt = 0; attempt < MAX_LEASE_ADOPTION_ATTEMPTS; attempt++) { + const at = now(); + const path = liveLeasePath(observed, ext, at); + if (!path) { + // A LIVE LEASE THIS REQUEST DISAGREES WITH IS A REFUSAL, never a + // fall-through: the destructive branch would repath and rotate a + // lease whose signed URL is still in somebody's hands. + if (hasLiveLease(observed, at)) { + return { + kind: "identity-conflict", + field: "mime", + expiresAt: observed.uploadUrlExpiresAt as Date, + }; + } + // Nothing live relies on this row any more, so the caller's + // destructive branch is safe. On a re-read this can also mean the + // winner took a NEW lease at a new path, which is equally a + // "not my business" answer. + return null; + } + if (!sha256Agrees(observed, expectedSha256)) { + return { + kind: "identity-conflict", + field: "sha256", + expiresAt: observed.uploadUrlExpiresAt as Date, + }; + } + + // The generation this adoption will hand back. A row that already has + // one keeps it; a legacy row that never had one (null) gets a fresh + // value, and the CAS below pins the null so only one writer mints it. + const uploadLease = observed.uploadLeaseNonce ?? (deps.nonce ?? newLeaseNonce)(); + + const { count } = await deps.db.updateMany({ + where: { id: observed.id, storagePath: observed.storagePath, ...leaseFence(observed) }, + data: { + // `rearm` carries a recovery's own state writes (clearing the + // verified hash, cancelling a retry). It must NOT carry the + // lease's identity -- see the guards above: a live lease's + // expectedSha256 and mime are fixed, and writing them here is + // exactly how two callers came to share one generation for two + // different documents. + ...rearm, + // ADOPTED, not overwritten: a legacy lease that announced no + // hash takes this request's, which sha256Agrees just allowed. + expectedSha256: observed.expectedSha256 || expectedSha256 || null, + uploadUrlExpiresAt: extendedExpiry(observed.uploadUrlExpiresAt, deps.expiresAt()), + uploadLeaseNonce: uploadLease, + }, + }); + + if (count === 0) { + // Somebody else wrote the row. Re-read — but only retry if what is + // there now is still the lease this caller decided about. Anything + // else is a conflict, and NEVER a fall-through to the destructive + // branch on the strength of a row that has demonstrably changed. + const fresh = await deps.reload(observed.id); + if (!fresh || !sameLease(row, fresh)) return { kind: "conflict" }; + observed = fresh; + continue; + } + + // THE ONE PLACE AN UPSERT-CAPABLE TOKEN IS ISSUED. + // + // This is the reuse path: the path already exists as far as the client + // is concerned, and the whole point is to let it replace a partial or + // superseded upload of its own. Every other issuer signs a path a + // version bump has just made new, so a create-only token is enough + // there and the weaker capability is what they get (see + // createReceiptUploadUrl). + const signed = await deps.sign(path, { upsert: true }); + if (!signed) return { kind: "storage-unavailable" }; + + // POST-SIGN REVALIDATION. See the header: the sign is a round trip, and + // a lease returned without re-reading may already be superseded. + const confirmed = await deps.reload(observed.id); + if (!confirmed || !sameLease(row, confirmed)) return { kind: "conflict" }; + if (confirmed.uploadLeaseNonce !== uploadLease) { + // Another adopter re-stamped the generation between our write and + // our signing. Converge on theirs rather than returning a nonce + // /finalize would refuse. + observed = confirmed; + continue; + } + + return { kind: "signed", signed: { ...signed, uploadLease } }; + } + + // Every attempt lost. The client retries the whole call and reads whatever + // the winner left, which is the same answer a lost CAS has always given. + return { kind: "conflict" }; +} + +/** + * IS THE LEASE THIS RESPONSE IS ABOUT TO RETURN STILL THE PERSISTED ONE? + * + * The three branches that mint a genuinely new lease — the create, the resume + * repath and the re-arm repath — all write the row, then sign, then answer. The + * sign is a network round trip, and a concurrent /start can adopt or repath the + * row while it is in flight; the nonce those branches were returning was simply + * the one they had generated, never re-checked. A client that acted on it got + * `409 lease-stale` from /finalize for a URL it had just been given. + * + * `reuseLiveLease` handles its own supersession by looping, because it can: + * adopting an existing lease again is idempotent. These branches cannot — their + * write was destructive and re-running it would repath the row a second time — + * so a superseded lease is answered as the retryable publish-conflict the + * caller already has, and the client re-runs /start. + */ +export async function issuedLeaseIsCurrent( + id: string, + expect: { storagePath: string; uploadLease: string }, + reload: (id: string) => Promise, +): Promise { + const fresh = await reload(id); + return !!fresh + && fresh.storagePath === expect.storagePath + && fresh.uploadLeaseNonce === expect.uploadLease; +} + +/** The lease /start just created, as the request that created it knows it. */ +export interface CreatedLease { + id: string; + storagePath: string; + uploadLeaseVersion: number; + uploadUrlExpiresAt: Date; + /** The generation THIS request wrote. The discard below pins it exactly. */ + uploadLeaseNonce: string; +} + +export interface DiscardClient { + deleteMany(args: { where: Record }): Promise<{ count: number }>; +} + +/** + * THROW AWAY A ROW WHOSE URL WAS NEVER ISSUED — BUT ONLY IF NOBODY RESUMED IT. + * + * /start creates the row FIRST and signs the upload URL SECOND, so a signer + * failure leaves a STAGING row for an upload that will never happen and the + * row has to go: it holds the sourceRef, and while it does, every honest retry + * is answered about a lease its caller was never given. + * + * The delete used to be unconditional (`delete({ where: { id } })`), and that + * is a race with the retry path this module exists for. A concurrent /start for + * the same sourceRef hits the unique violation, finds this very row with a LIVE + * lease, and reuseLiveLease hands it a working signed URL over the same path — + * all of which can complete while the original request is still waiting on its + * own failing signer. The unconditional delete then removed the row the retry + * had just adopted: the retry's bytes landed at a path no row pointed at, + * /finalize 404'd on an id that no longer existed, and the sourceRef's + * protection against a DIFFERENT document reusing it was gone with it. + * + * So the delete is a CAS over the lease THIS request wrote. Every way another + * request can adopt the row writes `uploadLeaseNonce`, and each also moves one + * of the other pinned columns: + * - reuseLiveLease extends `uploadUrlExpiresAt` (same path, same version) + * - the resume branch bumps `uploadLeaseVersion` and repaths `storagePath` + * - anything that publishes or parks it moves `state` off STAGING + * + * THE EXPIRY IS NOT ENOUGH ON ITS OWN, which is what the previous round got + * wrong. It reasoned that a retry can only reach the reuse AFTER this INSERT + * committed, so its `expiresAt()` must read strictly later than ours. That is + * an argument about ORDER, and this CAS needs INEQUALITY: production issues + * both the initial and the resumed expiry as "now + 2h", `Date.now()` has + * millisecond resolution, and two requests a few hundred microseconds apart — + * or on two hosts whose clocks are merely close — write the SAME instant. The + * pin then matched, the row another request had just adopted was deleted, its + * bytes landed at a path nothing pointed at, and /finalize 404'd. + * + * `uploadLeaseNonce` closes that: it is a fresh random value on every issue and + * every adoption, so "nobody wrote this column after I did" is a property of + * the value itself rather than of a clock. The other three columns stay in the + * fence — they are cheap, and each one independently rules out a class of + * adopter. + * + * `resumed` is not an error: somebody else owns this row and their URL works. + * The caller answers the idempotent conflict rather than reporting a failure + * for a row that is alive and in good hands. + */ +export async function discardUnresumedLease( + created: CreatedLease, + db: DiscardClient, +): Promise<"discarded" | "resumed"> { + const { count } = await db.deleteMany({ + // THE SHARED BUILDER, like every other lease-bearing write. The row was + // INSERTed by this request moments ago, so its state is "STAGING" and + // its reason and claim are null by construction — stating them through + // leaseFence costs nothing and keeps this delete inside the one rule + // rather than beside it. `storagePath` rides along for the same reason + // the reject's does: a delete must be sure which object it accounts for. + where: { + id: created.id, + storagePath: created.storagePath, + ...leaseFence({ + state: "STAGING", + stateReason: null, + uploadLeaseVersion: created.uploadLeaseVersion, + uploadLeaseNonce: created.uploadLeaseNonce, + uploadUrlExpiresAt: created.uploadUrlExpiresAt, + }), + }, + }); + return count > 0 ? "discarded" : "resumed"; +} diff --git a/src/lib/receipt-intake/worker.ts b/src/lib/receipt-intake/worker.ts new file mode 100644 index 000000000..ff6eb8781 --- /dev/null +++ b/src/lib/receipt-intake/worker.ts @@ -0,0 +1,1331 @@ +/** + * The intake worker — one pass over the claimed rows + * (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §5). + * + * Dry-run is the safety property this whole file exists to protect: with + * `RECEIPT_INTAKE_DRYRUN` unset or "true" (the default), a row is read, + * deduped and routed, and then STOPS. No QuickBooks call, no Expense row. The + * proof is a test, not a comment: tests/receipt-intake-worker.test.ts drives a + * full pass with injected fakes and asserts createPurchase was called zero + * times and no Expense was created. + * + * Everything external is injected so that test needs no database, no network, + * and no module mocking (CI is Node 20, where `mock.module` corrupts the + * require chain). + */ +import { Prisma } from "@prisma/client"; +import { canonicalVendor, dedupKeys } from "./keys"; +import { dayKeyInTimeZone, startOfDateInTimeZone } from "@/lib/tz-date"; +import { + backoffMs, + MAX_BOOK_ATTEMPTS, + routeState, + TAX_IMPLAUSIBLE_REASON, + type DedupHits, + type ReceiptIntakeState, +} from "./route-state"; +import { + appliedTaxCents, + buildGroups, + resolveSuggestedCostCodeId, + type BookableRow, + type BookResult, +} from "./book"; +import { QBTimeoutError } from "@/lib/quickbooks"; +import { + QboAccountConfigError, + QboPurchaseFaultError, + QboVendorDuplicateError, +} from "@/lib/qbo-receipt-push"; +import { READ_BUDGET_MS, type ProjectPhase, type ReadOutcome } from "./read"; +import type { VerifiedBytes } from "./stored-object"; +import { STORAGE_TIMEOUT_MESSAGE } from "./bucket"; + +/** + * ONE global constant, deliberately not derived from anything per-row or + * per-deployment: `pg_try_advisory_xact_lock(hashtextextended(CLAIM_LOCK_KEY,0))` + * is what guarantees a single worker BATCH runs at a time across every + * concurrent invocation of the cron. A key that varied by row, region, or + * process would let two batches run together, and the weak-dedup net (a plain + * SELECT, not a claim) would then miss a pair that arrived in the same tick. + */ +export const CLAIM_LOCK_KEY = "receipt-intake-worker"; +export const BATCH_SIZE = 10; +/** How long a claimed row is hidden from the next run. */ +export const CLAIM_LEASE_MINUTES = 10; + +/** + * The states whose ONLY remaining step is a QuickBooks write. + * + * A row here has already been read, deduped and routed. Nothing else happens + * to it in a pass: READ waits to be promoted to BOOKING, and BOOKING waits to + * be booked. Both are exactly what the dry-run switch forbids. + */ +export const QBO_WRITING_STATES = ["READ", "BOOKING"] as const; + +/** + * ELIGIBILITY IS A FUNCTION OF THE CURRENT GLOBAL SWITCH, not of the row alone. + * + * `row.dryRun` is written once at intake and never re-read, so it cannot + * express a ROLLBACK: flip `RECEIPT_INTAKE_DRYRUN` back on and every row that + * was claimed while the switch was off keeps `dryRun:false`. The worker loop + * already refuses to book those (the switch outranks the flag), but refusing + * INSIDE the loop is not enough when the batch is ten rows and the order is + * oldest-first: a few hundred old live rows are claimed, skipped, claimed + * again five minutes later, and no NEW receipt is ever read. The queue looks + * busy and processes nothing — the same starvation the dry-run park exclusion + * was written to prevent, arriving through the other door. + * + * So while the global switch says dry-run, a QBO-writing state is not + * claimable at all, whatever the row's own flag says. RECEIVED rows still are: + * reading and routing is precisely what the shadow week is for. + */ +export function claimableStates(dryRunGlobal: boolean): ReceiptIntakeState[] { + return dryRunGlobal ? ["RECEIVED"] : ["RECEIVED", ...QBO_WRITING_STATES]; +} + +/** + * The claim's whole eligibility predicate, in ONE place. + * + * Exported (rather than living inline in the cron route) so both the pure + * worker tests and the real-Postgres claim test assert against the same + * object the route actually claims with. A second copy of this predicate is + * how the loop and the claim came to disagree in the first place. + * + * STAGING is absent on purpose: the row exists but its object does not, so + * claiming it would park a good receipt as "file-missing". sweepStaleStaging + * is what watches those. + */ +export function eligibleClaimWhere(now: Date, dryRunGlobal: boolean): Prisma.ReceiptIntakeWhereInput { + return { + state: { in: claimableStates(dryRunGlobal) }, + OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }], + /** + * A row parked by the shadow week (dryRun=true, sitting at READ or + * BOOKING) is DONE until the cutover, and must be excluded rather than + * merely skipped inside the loop — for the same batch-starvation + * reason as above. runIntakeWorker's cutover is what brings them back, + * once, on the first live pass. Redundant while `dryRunGlobal` is true + * (those states are already off the list) and load-bearing when it is + * false. + */ + NOT: { AND: [{ dryRun: true }, { state: { in: [...QBO_WRITING_STATES] } }] }, + }; +} + +/** + * How long a row skipped by the global dry-run switch waits before it is + * looked at again. Same hour as book.ts's "a switch is off" deferral: nothing + * is wrong with the document, and hammering it every five minutes only costs + * batch slots that new receipts need. + */ +export const DRYRUN_PARK_RETRY_MS = 60 * 60_000; +/** + * Stop taking on NEW rows once this much of the 60s function budget is gone. + * One 25s read plus a QBO round trip can straddle the ceiling, and a row cut + * off mid-book is the one case where the lease is doing real work rather than + * being a formality. + */ +export const RUN_SOFT_DEADLINE_MS = 40_000; +/** + * The invocation's real ceiling (`maxDuration = 60`), minus a small margin so a + * booking that starts near the edge still gets to write its result. Bookings + * measure their runway against THIS, not the soft deadline. + */ +export const RUN_HARD_BUDGET_MS = 55_000; +/** + * How long a row may sit in STAGING before it is presumed to have lost its + * upload. Generous on purpose: the intake route uploads inline, so a row that + * is still STAGING after this either crashed mid-request or hit a storage + * outage, and neither resolves itself. + */ +export const STAGING_SWEEP_MINUTES = 15; +/** Storage round trips per sweep. Small: the sweep runs before any real work. */ +export const STAGING_SWEEP_BATCH = 10; +/** + * Supabase signed upload URLs are valid for two hours. A STAGING row younger + * than that may still have its bytes arrive, so declaring it file-missing at + * the 15-minute sweep window was premature — the row went to review while its + * own upload link was still usable. + */ +export const SIGNED_UPLOAD_TTL_MS = 2 * 60 * 60_000; + +/** When a URL issued now stops working. Written to the row by /intake/start. */ +export function uploadLeaseExpiry(now: Date = new Date()): Date { + return new Date(now.getTime() + SIGNED_UPLOAD_TTL_MS); +} + +/** + * Runway reserved AFTER a read for the write that records its result — the + * row's applyRead/applyState commit — plus whatever this pass still has left + * to do before the invocation ends. A read given every last millisecond of + * the run's own budget could return right as the platform kills the + * function, and its outcome would never be written at all. + */ +export const READ_SAFETY_MARGIN_MS = 2_000; +/** + * Below this much runway (after the safety margin), starting a read is not + * worth it: the request itself needs at least this long to have any real + * chance of finishing. The row is handed back un-attempted — the same + * AI_UNAVAILABLE answer readReceipt gives for an exhausted budget — rather + * than begun and abandoned mid-flight when the invocation's own deadline + * lands. + */ +export const READ_MIN_BUDGET_MS = 5_000; + +/** + * How much of `READ_BUDGET_MS` a read starting now may actually use, given + * how much runway is left in the WHOLE invocation. + * + * `read.ts`'s own READ_BUDGET_MS (25s) is sized against a fresh 60s + * invocation and assumes it is the first thing to run. It is not: a batch of + * ten rows can reach its ninth row 45 seconds in, and handing that read + * another full 25 seconds is what let a row started at 40s still be reading + * at 65s — past the `maxDuration = 60` ceiling the whole run is supposed to + * respect. This caps the read's OWN budget at whatever is actually left, so + * the invocation's one deadline governs every read the same way it already + * governs every QuickBooks call (see `deps.book`'s `deadline`). + * + * Pure and exported so this is a unit test, not a fact about `buildDeps` + * that nothing without a live cron invocation could ever exercise. + */ +export function readBudgetFor(remainingRunMs: number): number { + const budget = Math.min(READ_BUDGET_MS, remainingRunMs - READ_SAFETY_MARGIN_MS); + return budget < READ_MIN_BUDGET_MS ? 0 : budget; +} + +/** + * Could the object still arrive under a live upload URL? + * + * ROW AGE IS THE WRONG QUESTION for a two-step row. One whose URL was re-issued + * (a resumed /start, or a re-arm after the sweeper parked it) is older than its + * lease, and judging it on createdAt declared a receipt missing — or destroyed + * one it called unacceptable — while the client's own upload link was still + * live and about to land. `uploadUrlExpiresAt` is what /start actually + * promised, so that is what is honoured. + * + * NULL IS NOT A TWO-HOUR GRACE. It means no signed URL was ever issued: the + * single-shot path writes its bytes through the server inside one request, so + * such a row is either published or it failed mid-request. Giving it the + * SIGNED-URL TTL made every inline STAGING orphan invisible to the sweep for + * two hours, waiting on a URL that does not exist. Its grace is the stale- + * STAGING threshold, the same one the sweep selects on. + */ +export function uploadLeaseActive( + row: { uploadUrlExpiresAt?: Date | null; createdAt: Date }, + now: Date = new Date(), +): boolean { + return leaseDeadline(row).getTime() > now.getTime(); +} + +/** + * The instant this row's upload capability dies. `uploadLeaseActive` is + * literally "is that instant still ahead of us", so the two can never + * disagree about a row. + */ +function leaseDeadline(row: { uploadUrlExpiresAt?: Date | null; createdAt: Date }): Date { + if (row.uploadUrlExpiresAt) return row.uploadUrlExpiresAt; + return new Date(row.createdAt.getTime() + STAGING_SWEEP_MINUTES * 60_000); +} + +/** + * How long after a signed upload URL expires an object it could still have + * written may be deleted. + * + * A PUT that started one millisecond before the expiry is still in flight + * after it — Supabase validates the token when the request arrives, not when + * it completes — so deleting at the expiry itself can still race a write that + * was authorised. Five minutes is comfortably longer than an 8 MB upload. + */ +export const CLEANUP_GRACE_MS = 5 * 60_000; + +/** + * WHEN AN OBJECT AT THIS ROW'S PATH MAY BE DELETED — null meaning "now". + * + * A signed upload URL is a WRITE CAPABILITY, and it does not stop working + * because the row that requested it was rejected, published elsewhere, or + * re-pathed. Deleting the object while the URL is live only opens a window: + * the holder's delayed PUT recreates it (the URL is `upsert`-capable on the + * resume path), and nothing then references it, nothing remembers it, and no + * sweep is looking for it. The delete has to happen AFTER the capability + * dies, not before. + * + * This is the exact inverse of `uploadLeaseActive` — the same rule the + * stale-STAGING sweep already applies before it parks or rejects anything — + * so rejected-row cleanup and the sweep agree by construction rather than by + * two authors remembering the same thing. + * + * Null when the capability is ALREADY dead: there is nothing left to wait for + * and an immediate delete is correct. + */ +export function cleanupNotBefore( + row: { uploadUrlExpiresAt?: Date | null; createdAt: Date }, + now: Date = new Date(), +): Date | null { + if (!uploadLeaseActive(row, now)) return null; + return new Date(leaseDeadline(row).getTime() + CLEANUP_GRACE_MS); +} +/** + * Consecutive AI-unavailable passes before a row is parked for a human. Ported + * from v3.4: an outage that never ends still has to end somewhere, and 20 + * passes at 5 minutes each is over an hour of "we tried". + */ +export const MAX_BUSY_PASSES = 20; + +/** The columns a pass needs. A superset of BookableRow. */ +export interface WorkerRow extends BookableRow { + state: string; + /** What finalize recorded. Every download is checked against it. */ + fileSha256: string; + /** The token this pass claimed the row with. Completing writes are fenced on it. */ + claimToken: string | null; + fileSize: number; + readAt: Date | null; + dedupWeakKey: string | null; + busyPasses: number; + /** + * The fallback transaction date when the document's own date is + * unreadable. v1 used the Drive UPLOAD date (:1509); the intake row is + * created when the file arrives, so this is the same semantic — and, + * unlike "now", it does not drift when a read is delayed by an outage. + */ + createdAt: Date; +} + +export interface WorkerDependencies { + /** + * ONE transaction under the global advisory lock: optionally requeue the + * shadow-week backlog, then claim up to BATCH_SIZE due rows and bump their + * nextRetryAt. Returns null when another run holds the lock. + * + * The requeue lives INSIDE this transaction rather than beside it: run + * outside the lock, two overlapping invocations could both see the parked + * backlog and both un-park it, and the second one's UPDATE would race the + * first one's claim. + */ + /** + * Take the whole-invocation lease, or null when another invocation holds a + * live one. Injected so the overlap rule is a unit test rather than a + * property only a production race could ever demonstrate. + */ + acquireLease: () => Promise<{ release: () => Promise } | null>; + claim: (opts: CutoverRequest) => Promise; + /** RECEIPT_INTAKE_DRYRUN is not "false". Injected so the cutover is testable. */ + isDryRunEnabled: () => boolean; + /** The instant v1 stopped booking. null = not recorded; the cutover then refuses. */ + cutoverBoundary: () => Promise; + /** + * Move STAGING rows older than STAGING_SWEEP_MINUTES to NEEDS_REVIEW + * `file-missing`, or PUBLISH them when the object is actually there. + * `shouldStop` bounds the pass: the sweep downloads objects, so it must not + * be able to eat the invocation before any real work starts. + */ + sweepStaleStaging: (shouldStop: () => boolean) => Promise; + /** Retry storage deletes that failed when a row was rejected. */ + retryStorageCleanups: (shouldStop: () => boolean) => Promise; + loadPhases: (projectId: string | null) => Promise<{ id: string; code: string; name: string }[]>; + /** + * Re-read the row's projectId immediately before routing. + * + * The claim snapshot can be stale by seconds: /finalize accepts a late job + * assignment while a row is unclaimed, and the Gemini read that runs in + * between takes 25 seconds. Routing on the snapshot published NEEDS_JOB for + * a receipt that HAS a job by then — and NEEDS_JOB is where a human goes + * looking for exactly that problem. + */ + refreshProjectId: (rowId: string) => Promise; + /** + * The PERSISTED send flag, re-read at park time. + * + * `row.sendAttempted` is the value this pass CLAIMED with, so it is stale + * the moment the booking marks a send — and the booking marks it precisely + * so the fact survives a process that dies mid-create. A park decided on + * the snapshot released the dedup key of a row that has a Purchase in the + * real books, and the next submission of the same receipt booked it twice. + * + * Failing to read it means RETAINING the key: holding one against a + * booking that did not happen sends a resubmission to a human, and that is + * a queue item. Releasing one against a booking that did happen is a + * duplicate payment. + */ + sendAttemptedNow: (rowId: string) => Promise; + /** + * Tagged, and VERIFIED: the bytes must hash to what the row recorded at + * finalize. A sha stored once and never re-checked proves nothing about + * what is being read now. + */ + downloadBytes: (storagePath: string, expectedSha256: string) => Promise; + read: (bytes: Buffer, mime: string, phases: ProjectPhase[]) => Promise; + /** + * Persist the read + routing. Returns the strong-key owner when the partial + * unique index rejected our claim — that rejection IS the dedup hit. + */ + /** + * CAS'd on {id, state, claimToken} like every other mutation. `owned:false` + * means this worker lost the row mid-pass and must abort — writing on would + * clobber whatever its successor has since decided. + * + * THE ONE WRITE THAT KEEPS THE CLAIM, and the type says so: its state is + * pinned to "RECEIVED" because routing is not finished when it lands — the + * strong claim, the weak net and the publish all still have to happen under + * this same lease. Every TERMINAL outcome goes through applyState instead, + * which releases ownership in the same fenced write. Writing a terminal + * state here would leave a finished row holding a claim, and a row that is + * done but still owned is a row nothing will touch again. + */ + applyRead: ( + rowId: string, + patch: ReadPatch & { state: "RECEIVED" }, + ownership: Ownership, + ) => Promise<{ strongOwner: StrongOwner | null; owned: boolean }>; + findWeakHit: (rowId: string, weakKey: string) => Promise<{ id: string } | null>; + /** Marks a row NEEDS_REVIEW / NON_RECEIPT / whatever routing decided, with no keys claimed. */ + applyState: ( + rowId: string, + state: ReceiptIntakeState, + stateReason: string | null, + patch: Partial | undefined, + /** REQUIRED. An unowned write clobbers whatever the successor decided. */ + ownership: Ownership, + ) => Promise; + /** + * READ + dryRun=false -> BOOKING, and the LAST weak-dedup check, taken + * inside the same transaction as the transition. Returns the conflicting + * row when another document with this weak key is already BOOKING/BOOKED. + */ + promoteToBooking: ( + rowId: string, + weakKey: string | null, + claimToken: string | null, + ) => Promise<{ promoted: boolean; conflictId?: string; stale?: boolean }>; + /** The pass's ONE absolute deadline — never a snapshot of "time left". */ + book: (row: BookableRow) => Promise; + /** CAS'd on the claim: a superseded worker's result must write nothing. */ + applyBookResult: (rowId: string, result: BookResult, claimToken: string | null) => Promise; + /** AI unavailable: park for a later pass WITHOUT spending an attempt. */ + deferRead: (rowId: string, busyPasses: number, reason: string, ownership: Ownership) => Promise; + /** + * HAND THE ROW BACK, unchanged except for when to look at it again. + * + * A claim is what makes a row invisible to the next pass, so any path that + * finishes with a row WITHOUT completing, deferring or parking it still has + * to release ownership — otherwise the row is owned by a pass that has + * ended, every fenced write misses it, and it sits until its lease lapses. + * Used by the dry-run skip: nothing about the document is wrong, so it + * costs no `attempts` and changes no state; it just stops occupying a batch + * slot that a new receipt needs. + */ + releaseClaim: (rowId: string, nextRetryAt: Date, ownership: Ownership) => Promise; + /** + * HAND BACK EVERY ROW THIS PASS CLAIMED AND NEVER LOOKED AT. + * + * The claim takes BATCH_SIZE rows in one transaction and stamps them all + * with a lease (`nextRetryAt = now + LEASE_MS`, ten minutes). The loop then + * stops at the soft deadline — and the rows it never reached kept that + * lease AND their claim token, so the next cron five minutes later could + * not see them at all: `eligibleClaimWhere` skips a row whose `nextRetryAt` + * is in the future, and every fenced write misses a token no live pass + * holds. A batch that deadlocked on its first row sat idle for the rest of + * the ten minutes with nine untouched receipts behind it. + * + * Token-fenced, like every other write here: a row whose token changed is + * owned by somebody else now and must not be handed back by this pass. + * `nextRetryAt` is cleared rather than set, so the next pass sees them as + * due immediately — they were never worked on, so there is nothing to + * back off from. + * + * Returns how many rows were actually released. + */ + releaseUnprocessed: (rows: { id: string; claimToken: string | null }[]) => Promise; + /** A transient fault anywhere else: spend an attempt and back off. */ + retryRow: ( + rowId: string, + attempts: number, + nextRetryAt: Date, + reason: string, + ownership: Ownership, + ) => Promise; + /** + * RECEIVED -> READ, the release of the claim lease, AND the release of the + * claim token. Called ONCE, after every dedup net has answered — never + * before, or an overlapping run could reclaim a half-routed row and book it. + * + * FENCED on both the state and the token: a worker whose invocation was + * killed and whose row has since been re-claimed must not be able to + * publish READ over whatever its successor produced. Time-based leases + * cannot express that, because the zombie and the live worker hold + * identical row ids. + */ + finishRouting: ( + rowId: string, + claimToken: string | null, + stateReason: string | null, + /** The durable tax marker. READ is reached with no patch of its own. */ + taxWarning: string | null, + ) => Promise; + now: () => Date; + /** Elapsed-time source for the soft deadline. */ + monotonicMs: () => number; + /** The company's configured time zone — business dates are anchored to it, never UTC. */ + companyTimeZone: () => Promise; +} + +/** + * What a write must still be true of to be allowed. + * + * Every worker mutation is a CAS on this. `nextRetryAt` alone is a time-based + * lease and cannot distinguish a live worker from a zombie whose invocation was + * killed and whose row has since been re-claimed — both hold the same row id + * and both believe they own it. Zero rows affected means ownership was lost; + * the caller aborts rather than overwriting the successor's decisions. + */ +export interface Ownership { + state: string; + claimToken: string | null; +} + +export interface StrongOwner { + id: string; + totalCents: number | null; + canonicalVendor: string | null; +} + +export interface ReadPatch { + state: ReceiptIntakeState; + stateReason: string | null; + /** + * The dropped-tax-reading marker, in its DURABLE column. `stateReason` + * carries a copy for the queue to display, but every deferred booking + * and every park overwrites that column -- see preservedTaxWarning. + */ + taxWarning: string | null; + vendor: string | null; + txnDate: Date | null; + totalCents: number | null; + taxCents: number | null; + docType: string | null; + refNumber: string | null; + memo: string | null; + readJson: string | null; + readAt: Date; + dedupStrongKey: string | null; + dedupWeakKey: string; + duplicateOfId: string | null; + suggestedCostCodeId: string | null; + suggestedConfidence: number | null; +} + +export interface WorkerRunSummary { + processed: number; + byState: Record; + /** + * "lease-held": another invocation is mid-pass, so this one did nothing. + * "already-running": the claim's own advisory lock was taken — only + * reachable when a lease has expired under a still-running pass. + */ + skipped?: "already-running" | "lease-held"; + /** Rows left unprocessed because the soft deadline hit. */ + deferredToNextRun?: number; + /** + * How many of those `deferredToNextRun` rows were successfully handed back. + * A shortfall means some rows stayed claimed — either a successor had + * already taken them, or the release write failed — and those wait out + * their lease. + */ + releasedUnprocessed?: number; + /** Rows v1 already booked, retired as SHADOW_DONE by the first live pass. */ + shadowRetired?: number; + /** Rows received AFTER v1 stopped: nobody booked these, so they are handed to v2. */ + requeued?: number; + /** Held for a human: no v1 evidence AND no Drive identity to make v2 idempotent. */ + shadowQuarantined?: number; + /** + * Cutover rows whose fenced write matched nothing: they changed between the + * select that triaged them and the update that would have moved them, so + * the verdict was DROPPED rather than applied to a row it was not computed + * for. They come back round on the next pass. + */ + shadowSkippedMoved?: number; + /** The cutover could not run because no boundary is recorded. */ + cutoverBlocked?: "cutover-boundary-missing"; + /** STAGING rows whose upload never landed, parked for a human. */ + staleStagingSwept?: number; + /** Previously-failed object deletions that finally succeeded. */ + orphansCleaned?: number; +} + +function centsOf(amount: string): number | null { + const n = Number(amount); + if (!Number.isFinite(n)) return null; + return Math.round(n * 100); +} + +/** + * The calendar day the receipt was written, anchored in the COMPANY's time + * zone — not UTC. + * + * A receipt read as 2026-08-03 was stored as 2026-08-03T00:00:00Z, which in + * America/Los_Angeles is 5pm on August 2nd. Every date-range report that + * bounds by local midnight (job cost by month, the WA tax period, variance by + * week) therefore put roughly a third of receipts in the wrong bucket, and the + * error is invisible unless you already suspect it. Everything else in the app + * anchors business dates with startOfDateInTimeZone; this now does too. + */ +export function dateOnly(value: string, timeZone: string): Date | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + try { + const at = startOfDateInTimeZone(value, timeZone); + return Number.isFinite(at.getTime()) ? at : null; + } catch { + return null; + } +} + +/** + * The most sales tax a receipt can plausibly carry, as a fraction of the total. + * + * Washington's highest combined rate is about 10.6%; 12% leaves headroom for a + * local surcharge without accepting nonsense. The model reads the TAX line off + * a photo, and a misread decimal point ("$2.92" as "$292") or a grabbed + * subtotal posts real money to the reimbursable-sales-tax account and inflates + * a state filing. This is a SANITY bound, not a tax calculation — the tax that + * survives it is still whatever the document said. + */ +export const MAX_PLAUSIBLE_TAX_RATE = 0.12; + +/** + * Accept the OCR'd tax only when it is between zero and MAX_PLAUSIBLE_TAX_RATE + * of the total, rounded UP to the cent so a legitimate rounding artefact at the + * boundary is not rejected. + * + * An implausible value is DROPPED, not parked: the receipt itself is fine and + * its total is what the bank charge will match, so booking it is right. The row + * simply books as a single un-split line and carries a note, which is exactly + * what happens for a receipt with no readable tax line at all. + */ +export function validateTaxCents( + taxCents: number | null, + totalCents: number | null, + docType: string | null, +): { taxCents: number | null; implausible: boolean } { + // No tax line is the NORMAL case here, not a problem. + if (taxCents === null || taxCents <= 0) return { taxCents: null, implausible: false }; + + // A handwritten check to a subcontractor has no sales tax, full stop. If the + // model produced one it read the wrong number off the cheque — the amount + // box, a memo figure — and booking it would move real money into the + // reimbursable-sales-tax account for a payment that was never taxed. + // sendToQBOviaAPI.js:148 refuses to split tax on a check for the same + // reason; this makes the row SAY so instead of dropping it silently. + if (String(docType ?? "receipt").toLowerCase() === "check") { + return { taxCents: null, implausible: true }; + } + + if (totalCents === null || totalCents <= 0) return { taxCents: null, implausible: true }; + // Tax can never BE the total, let alone exceed it — that is a grabbed + // subtotal or a misread line, not a tax figure. + if (taxCents >= totalCents) return { taxCents: null, implausible: true }; + const ceiling = Math.ceil(totalCents * MAX_PLAUSIBLE_TAX_RATE); + if (taxCents > ceiling) return { taxCents: null, implausible: true }; + return { taxCents, implausible: false }; +} + +export function toDateStr(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** One pass. Never throws for a single bad row — one poison document must not stall the queue. */ +export interface CutoverRequest { + /** + * The CURRENT global switch, read ONCE per pass and handed down. + * + * ONE field, not a `run` flag beside it: the cutover runs exactly when the + * pass is live, and claim eligibility depends on the very same answer. Two + * fields that must always be each other's negation is how they drift, and + * a claim that disagreed with the loop about the switch is finding #1. + */ + dryRunGlobal: boolean; + /** + * The instant v1 stopped booking. Only rows received before it are even + * CANDIDATES for retirement — and each still needs its own evidence that v1 + * booked it. Never null when `dryRunGlobal` is false: the pass halts before + * claiming rather than proceed without it. + */ + boundary: Date | null; +} + +export interface ClaimResult { + rows: WorkerRow[]; + shadowRetired: number; + requeued: number; + /** Pre-boundary, no evidence, and no Drive identity — a human decides. */ + shadowQuarantined: number; + /** Rows that moved under the triage, so no cutover verdict was applied. */ + shadowSkippedMoved: number; +} + +export async function runIntakeWorker(deps: WorkerDependencies): Promise { + // MUTUAL EXCLUSION FOR THE WHOLE PASS, taken before anything is read, + // claimed or booked. + // + // The claim transaction's advisory lock is transaction scoped: it is gone + // the moment that transaction commits, which is BEFORE the first Gemini + // read and long before any QuickBooks write. So it never made the worker + // non-overlapping — it only made the claim itself atomic. A second + // invocation could (and, at five-minute cron spacing against 60-second + // passes, eventually would) claim a different batch and run alongside. + // This lease is what the "one worker at a time" property actually rests + // on; the per-row claim token is the layer under it that keeps an overlap + // harmless rather than merely unlikely. + const lease = await deps.acquireLease(); + if (!lease) return { processed: 0, byState: {}, skipped: "lease-held" }; + try { + return await runIntakePass(deps); + } finally { + // In a `finally`, so a throw out of the pass releases it too. Without + // that, one crash wedges the queue for a whole lease TTL. + await lease.release(); + } +} + +async function runIntakePass(deps: WorkerDependencies): Promise { + // THE DEADLINE STARTS HERE, at invocation entry — not after the claim and + // the sweep. The sweep downloads objects, so timing it out of the budget + // meant it could consume the whole platform timeout and the worker would + // STILL go on to start a 25s Gemini read and a QBO round trip. + const startedAt = deps.monotonicMs(); + const outOfTime = () => deps.monotonicMs() - startedAt >= RUN_SOFT_DEADLINE_MS; + + // CUTOVER. Rows received while dry-run was on were booked by v1, so v2 must + // never book them: they are RETIRED as SHADOW_DONE, not requeued. + // + // Requeuing them was a double-booking hazard. v2's QBO identity for an + // email/chat/mobile/web row is the intake UUID, which v1 never saw, so + // QuickBooks' DocNumber idempotency could not recognise a Purchase v1 had + // already created for the same document — and the whole shadow backlog + // would have been booked a second time, on real books, in one pass. + // The shadow backlog splits on ONE timestamp: when v1 stopped booking. + // Everything before it was booked by v1 and is retired to SHADOW_DONE; + // everything after it was booked by NOBODY and must be handed to v2, or + // those receipts are silently dropped. Nothing in the database can infer + // that instant, so with no boundary recorded the pass refuses to touch + // either side and says so. + // + // READ ONCE, USE EVERYWHERE. The switch decides three things in this pass — + // whether the cutover runs, which states are even claimable, and whether a + // claimed row may book — and they have to be the same answer. Calling + // isDryRunEnabled() separately at each of those points is what let the + // claim hand out rows the loop then refused, forever. + const dryRunGlobal = deps.isDryRunEnabled(); + const runCutover = !dryRunGlobal; + const boundary = runCutover ? await deps.cutoverBoundary() : null; + + // HALT THE WHOLE PASS, before anything is claimed. + // + // Refusing only the retire/requeue was not enough: the pass went on to claim + // and BOOK rows while the shadow backlog sat in an undecided state. Live + // mode with no recorded boundary means we cannot tell which rows v1 already + // booked, and booking anything under that uncertainty is the double-booking + // this whole mechanism exists to prevent. Nothing is touched until an + // operator records the boundary. + if (runCutover && !boundary) { + console.error("[cron/receipt-intake-worker] cutover-boundary-missing: halting the pass, nothing claimed"); + return { processed: 0, byState: {}, cutoverBlocked: "cutover-boundary-missing" }; + } + + const claimed = await deps.claim({ dryRunGlobal, boundary }); + if (claimed === null) { + return { processed: 0, byState: {}, skipped: "already-running" }; + } + const { rows, shadowRetired, requeued, shadowQuarantined, shadowSkippedMoved } = claimed; + + // Rows whose upload never landed are invisible to the claim by design, so + // this is the only thing that will ever notice them. + const staged = await deps.sweepStaleStaging(outOfTime).catch(() => 0); + // Orphaned objects from rejected rows. Nothing else remembers them. + const cleaned = await deps.retryStorageCleanups(outOfTime).catch(() => 0); + + const byState: Record = {}; + const bump = (state: string) => { byState[state] = (byState[state] ?? 0) + 1; }; + + let processed = 0; + let deferredToNextRun = 0; + + for (const row of rows) { + // A row started at 41s can still be reading at 66s, past the function + // ceiling — the invocation dies mid-book and the row's state is + // whatever it happened to be. Stop TAKING rows instead, and RELEASE the + // ones we never reached (below): keeping them would leave rows this + // pass never looked at holding a ten-minute lease under a token nobody + // owns, invisible to the next cron five minutes later. + if (outOfTime()) { + deferredToNextRun = rows.length - processed; + break; + } + processed++; + // THE ROW AS THE DATABASE NOW HOLDS IT, not as the claim handed it over. + // + // Every recovery write below (retryRow, applyState, releaseClaim) is + // CAS'd on `ownershipOf(...)`, i.e. on {state, claimToken}. The loop + // MOVES the state mid-row — READ -> BOOKING, committed by + // promoteToBooking — so a throw after that promotion was handed the + // ORIGINAL row and its CAS pinned state "READ", which no longer + // existed. It matched zero rows, `retryRow` reported false, the error + // was bumped as STALE, and `attempts` never moved: a persistent + // pre-send failure (a QBO auth outage, a poisoned vendor lookup) + // cycled the same row forever, never backing off and never reaching + // the max-retries park that exists to put it in front of a person. + // + // So the promotion's result is carried forward, and it is THIS value + // every error path is given. + let current = row; + try { + if (row.state === "RECEIVED") { + bump(await processReceived(row, deps)); + } else if (row.state === "READ") { + // Dry-run rows PARK at READ. This is the shadow-week gate: a + // row only moves to BOOKING when BOTH its persisted flag AND + // the CURRENT global switch say live. The persisted flag alone + // is not a kill switch — it is written once at intake and + // never rechecked, so a row claimed while RECEIPT_INTAKE_DRYRUN + // was off keeps dryRun=false even after the switch is reverted + // to stop live QBO writes. + // + // `dryRunGlobal` is this pass's ONE reading of the switch, the + // same one the claim used, so a row can no longer be handed out + // as claimable and then refused here. Belt and braces all the + // same — and the release is what makes the belt safe: a skip + // that kept the claim left the row owned by a finished pass. + const live = !row.dryRun && !dryRunGlobal; + if (!live) { bump(await parkForDryRun(row, deps)); continue; } + const promotion = await deps.promoteToBooking(row.id, row.dedupWeakKey, row.claimToken); + if (promotion.stale) { + // Superseded between the claim and the promotion. The + // successor owns this row; write nothing, book nothing. + bump("STALE"); + continue; + } + if (!promotion.promoted) { + // Another document with the same canonical vendor, date and + // amount reached BOOKING/BOOKED first. Two same-day, same- + // amount purchases from one vendor are real, so this asks a + // human rather than quarantining — but it must ask BEFORE + // the money moves, which is why the check lives inside the + // transition rather than beside it. + bump("NEEDS_REVIEW"); + continue; + } + // THE PROMOTION COMMITTED, so the row's state is BOOKING from + // here on and every CAS below must pin that, not the claimed + // "READ". The claim token is unchanged — promoteToBooking is + // fenced on it and does not reissue it — so the rest of the + // ownership tuple still holds. + current = { ...row, state: "BOOKING", dryRun: false }; + const result = await deps.book(current); + await deps.applyBookResult(current.id, result, current.claimToken); + bump(stateForBookResult(result)); + } else if (row.state === "BOOKING") { + if (row.dryRun || dryRunGlobal) { bump(await parkForDryRun(row, deps)); continue; } + const result = await deps.book(row); + await deps.applyBookResult(row.id, result, row.claimToken); + bump(stateForBookResult(result)); + } + } catch (error) { + bump(await handleRowError(current, deps, error)); + } + } + + // THE ROWS THE DEADLINE CUT OFF ARE HANDED BACK, not left leased. + // + // `processed` is incremented BEFORE a row is worked on, so `rows.slice` + // from it is exactly the set nothing was ever attempted against — a row + // that threw is `processed` and was already routed through handleRowError, + // which releases it. Best-effort: a release that fails leaves the row + // exactly as the old code did, waiting out its lease, which is strictly no + // worse than not trying. + let releasedUnprocessed = 0; + if (deferredToNextRun > 0) { + releasedUnprocessed = await deps + .releaseUnprocessed(rows.slice(processed).map(r => ({ id: r.id, claimToken: r.claimToken }))) + .catch(() => 0); + } + + return { + processed, + byState, + ...(deferredToNextRun ? { deferredToNextRun } : {}), + ...(releasedUnprocessed ? { releasedUnprocessed } : {}), + ...(shadowRetired ? { shadowRetired } : {}), + ...(requeued ? { requeued } : {}), + ...(shadowQuarantined ? { shadowQuarantined } : {}), + ...(shadowSkippedMoved ? { shadowSkippedMoved } : {}), + ...(staged ? { staleStagingSwept: staged } : {}), + ...(cleaned ? { orphansCleaned: cleaned } : {}), + }; +} + +/** + * A row the dry-run switch will not let this pass advance. + * + * It keeps its state — nothing about it is decided, and the moment the switch + * goes live again it is claimable and bookable exactly as it was. What it does + * NOT keep is the claim: a skipped row that stayed owned by a finished pass is + * invisible to every fenced write until its lease lapses, and (before the + * eligibility fix above) came straight back into the next batch to be skipped + * again, crowding out the new receipts the shadow week exists to read. + * + * A release that FAILS means the row was already taken from us — report STALE + * rather than pretending the pass parked it. + */ +async function parkForDryRun(row: WorkerRow, deps: WorkerDependencies): Promise { + const released = await deps.releaseClaim( + row.id, + new Date(deps.now().getTime() + DRYRUN_PARK_RETRY_MS), + ownershipOf(row), + ).catch(() => false); + return released ? row.state : "STALE"; +} + +/** + * A throw out of a row's processing is almost never the document's fault: + * Supabase hiccuped, Prisma lost its connection, the settings read failed, a + * socket reset. Parking all of those for a human turns one bad minute into a + * queue full of manual work, and (worse) leaves rows holding their strong keys. + * + * Only the CLASSIFIED QuickBooks business faults are terminal here. Everything + * else spends an attempt and comes back on the normal backoff, with the same + * 20-attempt ceiling as booking so a genuinely broken row still ends up in + * front of a person. + */ +export async function handleRowError( + row: WorkerRow, + deps: WorkerDependencies, + error: unknown, +): Promise { + const message = error instanceof Error ? `${error.name}: ${error.message}` : "UnknownError"; + + if (isTerminalQboFault(error)) { + // A CLASSIFIED QBO fault means the send happened, so parkTerminal will + // (correctly) keep the key — the decision is still made in one place. + return parkTerminal(row, deps, `qbo-fault:${message}`.slice(0, 400)); + } + + const attempts = row.attempts + 1; + if (attempts >= MAX_BOOK_ATTEMPTS) { + // Same rule as every other terminal park, applied in the same place. + return parkTerminal(row, deps, "max-retries"); + } + const ownedRetry = await deps.retryRow( + row.id, + attempts, + new Date(deps.now().getTime() + backoffMs(attempts)), + `worker-error:${message}`.slice(0, 400), + ownershipOf(row), + ).catch(() => false); + return ownedRetry ? "RETRY" : "STALE"; +} + +/** QBTimeoutError is deliberately NOT here — a timeout is transport, not a verdict. */ +export function isTerminalQboFault(error: unknown): boolean { + if (error instanceof QBTimeoutError) return false; + return ( + error instanceof QboPurchaseFaultError || + error instanceof QboAccountConfigError || + error instanceof QboVendorDuplicateError + ); +} + +function stateForBookResult(result: BookResult): string { + switch (result.outcome) { + case "booked": return "BOOKED"; + case "needs-review": return "NEEDS_REVIEW"; + case "deferred": return "BOOKING"; + case "retry": return "BOOKING"; + case "stale": return "STALE"; + } +} + +async function processReceived(row: WorkerRow, deps: WorkerDependencies): Promise { + const download = await deps.downloadBytes(row.storagePath, row.fileSha256); + if (!download.ok) { + // "The object is gone" and "storage was briefly unreachable" demand + // opposite answers, and collapsing them to null meant a Supabase blip + // parked good receipts as file-missing, permanently, for a human to + // untangle. Only an AFFIRMATIVE not-found is terminal. + if (download.kind === "missing") { + return parkTerminal(row, deps, "file-missing"); + } + // The stored bytes are not the ones this row was published with. + // Terminal, and loud: it means the object was replaced after + // verification, which is the exact thing sealing exists to prevent. + if (download.kind === "sha-mismatch") { + return parkTerminal(row, deps, "content-changed"); + } + // A TIMEOUT is tagged apart from every other transient storage fault, + // because only this one is self-inflicted enough to bound separately. + return retryTransient( + row, + deps, + download.message?.startsWith(STORAGE_TIMEOUT_MESSAGE) + ? `${STORAGE_TIMEOUT_PREFIX}1` + : `storage:${download.message}`, + ); + } + const bytes = download.bytes; + + const costCodes = await deps.loadPhases(row.projectId); + const phases: ProjectPhase[] = costCodes.map(c => ({ code: c.code, name: c.name })); + + const outcome = await deps.read(bytes, row.mimeType, phases); + if (!outcome.ok) { + // decisive: the model answered and still could not read it -> a human. + if (outcome.decisive) { + return parkTerminal(row, deps, "unreadable"); + } + // The SERVICE was unavailable. That is never the document's fault, so + // it costs no `attempts` — but it cannot be free forever either, or an + // outage that outlasts the incident leaves rows cycling silently. v3.4 + // counts the busy passes separately and gives up after 20. + const busyPasses = row.busyPasses + 1; + if (busyPasses >= MAX_BUSY_PASSES) { + return parkTerminal(row, deps, "ai-unavailable"); + } + const owned = await deps.deferRead(row.id, busyPasses, "ai-unavailable", ownershipOf(row)); + return owned ? "RECEIVED" : "STALE"; + } + + const read = outcome.read; + // Resolved BEFORE the keys: the fallback date is part of the dedup key, so + // it has to be the company's calendar day from the start. + const timeZone = await deps.companyTimeZone(); + const keys = dedupKeys({ + docType: read.docType, + vendor: read.vendor, + date: read.date, + invoice: read.invoice, + checkNumber: read.checkNumber, + totalAmount: read.totalAmount, + // The company's calendar day, not UTC's. `toISOString().slice(0,10)` + // rolls over at 16:00/17:00 local, so a receipt uploaded on a Pacific + // evening got TOMORROW's date as its fallback — changing its dedup key + // and its reporting period. + fallbackDateStr: dayKeyInTimeZone(row.createdAt, timeZone), + }); + + const totalCents = centsOf(keys.amount); + const taxCentsRaw = centsOf(read.taxAmount || "0.00"); + const tax = validateTaxCents( + taxCentsRaw && taxCentsRaw > 0 ? taxCentsRaw : null, + totalCents, + read.docType, + ); + + // PERSIST ONLY WHAT BOOKING WILL ACTUALLY USE. + // + // The row's taxCents feeds the sales-tax reports, and those must never show + // a figure that no Purchase ever carried. So the stored value is not the + // validated one — it is the value read back out of the SAME buildGroups the + // booking step calls. If the two ever disagree (a rule added on one side + // only), the row records the BOOKING's answer and is flagged, rather than + // quietly reporting a tax that was rejected downstream. + const accepted = totalCents !== null && totalCents > 0 + ? appliedTaxCents(buildGroups(read.docType, totalCents, tax.taxCents, keys.ref)) + : 0; + const taxCents = accepted > 0 ? accepted : null; + const taxImplausible = tax.implausible || (tax.taxCents !== null && taxCents === null); + + const base = { + // WRITTEN ONCE, HERE, and never touched again. The copy `note()` + // appends to `stateReason` is for the queue to show; this is the + // one the BOOKED transition reads, because stateReason is + // overwritten by every deferred booking and every park. + taxWarning: taxImplausible ? TAX_IMPLAUSIBLE_REASON : null, + vendor: read.vendor || null, + txnDate: dateOnly(keys.dateStr, timeZone), + totalCents, + taxCents, + docType: read.docType || null, + refNumber: keys.ref, + memo: read.memo || null, + readJson: read.raw, + readAt: deps.now(), + dedupWeakKey: keys.weak, + suggestedCostCodeId: resolveSuggestedCostCodeId(read.suggestedPhaseCode, costCodes), + // Stored beside the suggestion so the queue can sort by it and the + // booking can record how sure the phase pick was. + suggestedConfidence: read.suggestedConfidence, + }; + + // Re-read RIGHT BEFORE routing. Everything above — the download, a 25s + // model call — is time in which a late job assignment can have landed. + // + // A FAILED RE-READ IS NOT AN ANSWER ABOUT THE JOB. + // + // Swallowing the throw and falling back to the CLAIMED snapshot turned a + // pool timeout into a routing decision: the snapshot is by definition the + // row as it looked BEFORE the read, so when it carried no project and a + // person assigned one during those seconds, the fallback parked a receipt + // NEEDS_JOB for a job it already had. The person sees their own assignment + // ignored, and the row waits for a human that nothing will summon. + // + // So the two cases are split by what the fallback would actually assert: + // - snapshot has NO project: the fallback claims "still unassigned", + // which is exactly the fact the failed call was supposed to establish. + // Transient — normal backoff, attempt spent, claim handed back. + // - snapshot HAS a project: the fallback claims "this job", which the + // row itself already recorded and which a late assignment can only + // have refined, never removed (the column is SetNull on delete, and a + // deleted project is not a reason to re-read Gemini). The routing gate + // only asks whether a job exists at all, so the stale answer and the + // fresh one agree. It may stand. + const refreshed = await deps.refreshProjectId(row.id).then( + value => ({ ok: true, value } as const), + () => ({ ok: false, value: null } as const), + ); + if (!refreshed.ok && !row.projectId) { + return retryTransient(row, deps, "project-refresh-unavailable"); + } + const projectId = refreshed.ok ? refreshed.value : row.projectId; + const hasProject = !!projectId; + + const routeInput = { + docType: read.docType, + amount: keys.amount, + totalCents, + canonicalVendor: canonicalVendor(read.vendor), + }; + + // ORDER MATTERS, and it used to be wrong. + // + // The weak lookup ran FIRST, so an exact duplicate — same date, same ref, + // same vendor, same amount, which therefore matches BOTH nets — routed on + // the weak hit to NEEDS_REVIEW and never attempted the strong claim at all. + // The one case the strong key exists to resolve automatically was the one + // case it never got to see, and every re-sent receipt landed in a human's + // queue. + // + // So: the document-level gates first (multi, non-receipt, refund/zero, no + // job) because those outrank dedup entirely; then the STRONG claim, which + // is the only net that can answer DUPLICATE on its own; and only if the + // strong net is silent do we fall back to the weak one, which by design + // never decides anything itself. + // A dropped tax reading is recorded, never parked: the receipt is fine and + // its TOTAL is what the bank charge matches, so it must still book. The note + // rides along with whatever state routing picks so the row shows it in the + // queue. `note()` is applied to every write below rather than to one branch, + // because a document can be both a duplicate and a bad tax read. + const note = (reason: string | null): string | null => { + if (!taxImplausible) return reason; + return reason ? `${reason};tax-implausible` : "tax-implausible"; + }; + + const gate = routeState(routeInput, { strong: null, weak: null }, hasProject); + if (gate.state !== "READ") { + // A multi-doc, a non-receipt, or a $0/negative misread must never hold + // a dedup key — it would quarantine the real receipt that arrives next + // (:531 and the v3.6 rationale). + // + // Via applyState, NOT applyRead: this row is FINISHED — nothing else in + // this pass will touch it — so the write that parks it must also hand + // the claim back, atomically. applyRead deliberately keeps the lease + // (routing continues under it), which for a terminal outcome left a + // done row owned by a pass that had moved on: invisible to the health + // probe as anything but "claimed", and untouchable by every fenced + // write until the lease aged out. + // + // Safe to swap: the unique-violation path applyRead exists for cannot + // fire here, because a gated row claims no strong key at all. + const owned = await deps.applyState(row.id, gate.state, note(gate.stateReason), { + ...base, + state: gate.state, + dedupStrongKey: null, + duplicateOfId: gate.duplicateOfId, + }, ownershipOf(row)); + return owned ? gate.state : "STALE"; + } + + // The strong claim IS the partial unique index: a rejection is the hit. + // + // The row deliberately stays RECEIVED here, and keeps its claim lease. + // Publishing READ at this point was wrong twice over: + // - the lease was cleared before the weak lookup ran, so an overlapping + // invocation could reclaim the row and BOOK it while this one was still + // routing — and this one would then regress it to NEEDS_REVIEW. + // - if the weak lookup then threw, the row was left in READ having never + // been weak-checked. In shadow mode READ is a terminal parking state, + // so it would sit there forever while the daily comparison counted it + // as fully deduped. A silent false negative in the one report the + // cutover decision rests on. + // READ is now reached only by finishRouting(), after every net has spoken. + const applied = await deps.applyRead(row.id, { + ...base, + state: "RECEIVED", + stateReason: note(null), + dedupStrongKey: keys.strong, + duplicateOfId: null, + }, ownershipOf(row)); + // Lost the row mid-read. Everything after this — the strong claim, the weak + // net, the publish — would be decided on a view the successor has moved past. + if (!applied.owned) return "STALE"; + + if (applied.strongOwner) { + const second = routeState(routeInput, { strong: applied.strongOwner, weak: null }, hasProject); + const owned = await deps.applyState(row.id, second.state, note(second.stateReason), { + ...base, + dedupStrongKey: null, + duplicateOfId: second.duplicateOfId, + }, ownershipOf(row)); + return owned ? second.state : "STALE"; + } + + // No strong hit (or no strong key at all — a placeholder ref). The weak net + // is a plain query and never a claim (:1591-1596); a hit only ever asks a + // human, because two genuine same-day purchases from one vendor for the + // same amount do happen. + // + // A THROW here leaves the row RECEIVED with its keys already written, which + // is exactly right: the next pass re-runs the identical claim (updating a + // row to the strong key it already holds is a no-op, not a conflict) and + // re-checks the weak net. + const weak = await deps.findWeakHit(row.id, keys.weak); + if (weak) { + const third = routeState(routeInput, { strong: null, weak }, hasProject); + // RELEASE the strong key. Nothing was sent to QuickBooks, so this row + // is parked pre-send and the documented rule applies to it like any + // other. Holding the key made a CORRECTED resend of the same receipt + // collide with a row that was never booked — the review queue then had + // two rows and neither could proceed. The weak pair is still visible to + // a human through duplicateOfId and the reason. + const owned = await deps.applyState(row.id, third.state, note(third.stateReason), { + ...base, + dedupStrongKey: null, + duplicateOfId: third.duplicateOfId, + }, ownershipOf(row)); + return owned ? third.state : "STALE"; + } + + // Routing is complete. This is the ONLY path to READ, and the only place + // the claim lease is released. + await deps.finishRouting( + row.id, + row.claimToken, + note(null), + taxImplausible ? TAX_IMPLAUSIBLE_REASON : null, + ); + return "READ"; +} + +/** + * THE one place a row is parked terminally, and the one place the strong-key + * release is decided. + * + * The rule is a property of the ROW, not of the reason string: if no QBO send + * was ever attempted, no Purchase can exist, so the dedup key must go back or a + * corrected resubmission collides with a row that never became a purchase. That + * was previously re-derived at each call site, and the branches that forgot it + * (file-missing, unreadable, ai-unavailable, worker-error) each held a key + * against nothing. + * + * `sendAttempted` is the PERSISTED flag — markSendAttempted writes it before + * the create precisely so this decision survives a process that died mid-send, + * and it is RE-READ here rather than taken from the row this pass claimed. A + * failure anywhere after the send (the post-create phase check, the Expense + * commit, a pool timeout) reaches this function with a snapshot that still says + * "nothing sent", and releasing the key on that is a duplicate payment. + */ +async function parkTerminal( + row: WorkerRow, + deps: WorkerDependencies, + reason: string, + patch?: Partial, +): Promise { + // Re-read, never the claim-time snapshot: see sendAttemptedNow. + const sent = row.sendAttempted || await deps.sendAttemptedNow(row.id).catch(() => true); + const release = sent ? {} : { dedupStrongKey: null }; + const owned = await deps + .applyState(row.id, "NEEDS_REVIEW", reason, { ...(patch ?? {}), ...release }, ownershipOf(row)) + .catch(() => false); + // Zero rows means a successor owns this row now; its state is theirs to set. + return owned ? "NEEDS_REVIEW" : "STALE"; +} + +/** The row as this pass claimed it — what every CAS matches on. */ +export function ownershipOf(row: WorkerRow): Ownership { + return { state: row.state, claimToken: row.claimToken }; +} + +/** A transport-class fault during a row's processing: spend an attempt, back off. */ +/** + * How many storage calls in a row may time out on ONE object before it stops + * heading the queue. + * + * A hung object is not a transient fault after the third go: it is a document + * that costs the pass its whole storage budget every time it is claimed, and + * because the claim is oldest-first it is claimed FIRST every time. Three is + * enough to ride out a Supabase blip and few enough that a genuinely stuck + * object stops crowding out the receipts behind it. + */ +export const MAX_STORAGE_TIMEOUTS = 3; + +/** The marker `lastError` carries so the run length survives between passes. */ +export const STORAGE_TIMEOUT_PREFIX = "storage-timeout:"; + +/** + * How many CONSECUTIVE storage timeouts this row has now seen. + * + * The count lives in `lastError` rather than in a column of its own: it is a + * property of an unbroken run, it needs no migration, and `lastError` is + * already the column that records why the last pass gave up. Any other failure + * writes a different reason there, which is exactly what resets the run — so + * "consecutive" is enforced by the storage of the counter rather than by + * remembering to clear it. + */ +export function storageTimeoutRun(lastError: string | null | undefined): number { + const match = /^storage-timeout:(\d+)\b/.exec(lastError ?? ""); + return match ? Number(match[1]) : 0; +} + +/** A transport-class fault during a row's processing: spend an attempt, back off. */ +async function retryTransient(row: WorkerRow, deps: WorkerDependencies, reason: string): Promise { + const attempts = row.attempts + 1; + if (attempts >= MAX_BOOK_ATTEMPTS) { + // Through parkTerminal like every other terminal park, so the + // strong-key release is decided in exactly one place. + return parkTerminal(row, deps, "max-retries"); + } + // A STALLED OBJECT STOPS HEADING THE QUEUE. + // + // Every other transient fault is worth twenty attempts because it costs + // almost nothing to retry. A storage timeout is different: it burns the + // pass's whole storage budget, and the claim is oldest-first, so the same + // object hangs the next run and the one after that. Bounded separately, + // and parked with its own reason so a human sees WHY rather than a generic + // "max-retries" twenty passes later. + if (reason.startsWith(STORAGE_TIMEOUT_PREFIX)) { + const run = storageTimeoutRun(row.lastError) + 1; + if (run >= MAX_STORAGE_TIMEOUTS) return parkTerminal(row, deps, "storage-timeout"); + reason = `${STORAGE_TIMEOUT_PREFIX}${run}`; + } + const owned = await deps.retryRow( + row.id, + attempts, + new Date(deps.now().getTime() + backoffMs(attempts)), + reason, + ownershipOf(row), + ); + return owned ? "RETRY" : "STALE"; +} + +/** + * A unique-constraint violation. NOT specific to the strong key on purpose. + * + * The previous version string-matched "dedupStrongKey" inside `error.meta`, + * which is a Prisma-version-dependent shape AND is empty for a PARTIAL index on + * some engine builds — the exact index this whole mechanism relies on. The + * caller resolves which constraint fired by looking the owner up by + * dedupStrongKey, which is a fact about the DATA rather than about how Prisma + * happened to render the error. + */ +export function isUniqueViolation(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"; +} diff --git a/src/lib/secure-storage.ts b/src/lib/secure-storage.ts index 6dfd315b3..8ac2a06f6 100644 --- a/src/lib/secure-storage.ts +++ b/src/lib/secure-storage.ts @@ -1,4 +1,5 @@ import { getSupabase, STORAGE_BUCKET } from "./supabase"; +import { isReceiptUrlRef, resolveReceiptUrl } from "./receipt-intake/receipt-url"; /** * Private bucket for documents that carry legal or PII weight: e-signatures, executed @@ -98,6 +99,7 @@ export function parseOwnStorageUrl( /** * Turn a stored document reference into something a browser can load. * + * - receipt ref → short-lived signed URL against the receipts bucket * - secure ref → short-lived signed URL against the private bucket * - data: URL → returned unchanged (legacy inline signatures still render) * - absolute URL → returned unchanged (legacy public-bucket object, still served) @@ -112,6 +114,11 @@ export async function resolveDocUrl( ): Promise { if (!stored) return null; + // `receipt-intake:///` — what the receipt pipeline writes to + // Expense.receiptUrl. Handled here so EVERY existing reader resolves it, + // rather than each one learning a second scheme. + if (isReceiptUrlRef(stored)) return await resolveReceiptUrl(stored, ttlSeconds); + const securePath = secureRefPath(stored); if (securePath) { const supabase = getSupabase(); @@ -173,6 +180,111 @@ export async function resolveDocUrls( ); } +/** + * Why a download did not produce bytes. + * + * `downloadDocBytes` collapses every failure to `null`, which is fine for a PDF + * that renders without a signature but NOT for a money path: "the object is + * gone" and "Supabase was briefly unreachable" demand opposite responses. The + * first is terminal (a human must re-upload); the second must be retried, and + * treating it as terminal would park good receipts during a storage blip. + * + * `not-found` is only ever returned when storage AFFIRMATIVELY said the object + * is missing. Anything ambiguous — a network error, a 5xx, no configured + * client — is `transient`, because guessing "gone" on incomplete evidence is + * the failure mode that loses documents. + */ +export type DocBytesResult = + | { ok: true; bytes: Buffer } + | { ok: false; kind: "not-found" } + | { ok: false; kind: "transient"; message: string }; + +/** + * Storage's shapes for "this key does not exist", and ONLY those. + * + * `status === 400` used to count as not-found, which is badly wrong: Supabase + * returns 400 for a malformed request, a bad JWT, an expired service key, and + * assorted config faults. Any of those made the caller conclude the receipt was + * GONE — a terminal verdict that parks the row and RELEASES its dedup key — when + * the object was sitting there untouched. A rotated key would have emptied the + * queue into review and unlocked every key on the way out. + * + * So: an affirmative 404, or an explicit not-found error code. Everything else, + * including every other 4xx, is transient/config and retries. + */ +const NOT_FOUND_CODES = new Set(["nosuchkey", "not_found", "object_not_found", "entitynotfound"]); + +export function isNotFoundError( + error: { message?: string; status?: number; statusCode?: string | number; error?: string } | null, +): boolean { + if (!error) return false; + if (Number(error.status ?? error.statusCode) === 404) return true; + const code = String(error.error ?? "").toLowerCase().replace(/[\s-]/g, "_"); + if (NOT_FOUND_CODES.has(code)) return true; + const message = String(error.message ?? "").toLowerCase(); + // Exact phrases only — a substring like "not found" inside some other + // sentence is not evidence of absence. + return message === "object not found" || message === "the resource was not found"; +} + +/** + * Tagged download. Same resolution rules as downloadDocBytes (secure ref, data + * URL, our own storage URL, legacy bare path) but the caller is told WHY it + * failed. Use this on any path where a missing file changes what happens to + * real money. + */ +export async function downloadDocBytesResult( + stored: string | null | undefined, +): Promise { + if (!stored) return { ok: false, kind: "not-found" }; + + if (isDataUrl(stored)) { + const bytes = await downloadDocBytes(stored); + return bytes ? { ok: true, bytes } : { ok: false, kind: "not-found" }; + } + + let bucket: string; + let path: string; + + const securePath = secureRefPath(stored); + if (securePath) { + bucket = SECURE_BUCKET; + path = securePath; + } else if (/^https?:\/\//i.test(stored)) { + const parsed = parseOwnStorageUrl(stored); + // Not ours, or naming a bucket we never write absolute URLs for. That is + // a REFUSAL, not a transient failure — retrying cannot make it ours. + if (!parsed || parsed.bucket !== STORAGE_BUCKET) return { ok: false, kind: "not-found" }; + bucket = parsed.bucket; + path = parsed.path; + } else { + if (stored.startsWith("/") || stored.includes("..")) return { ok: false, kind: "not-found" }; + bucket = STORAGE_BUCKET; + path = stored; + } + + const supabase = getSupabase(); + // No client is a CONFIGURATION fault, not a missing object. Retry it. + if (!supabase) return { ok: false, kind: "transient", message: "storage-not-configured" }; + try { + const { data, error } = await supabase.storage.from(bucket).download(path); + if (error) { + return isNotFoundError(error as { message?: string; status?: number }) + ? { ok: false, kind: "not-found" } + : { ok: false, kind: "transient", message: String(error.message ?? "download-failed").slice(0, 200) }; + } + if (!data) return { ok: false, kind: "not-found" }; + return { ok: true, bytes: Buffer.from(await data.arrayBuffer()) }; + } catch (error) { + // A throw is a transport fault every time — never evidence of absence. + return { + ok: false, + kind: "transient", + message: error instanceof Error ? `${error.name}: ${error.message}`.slice(0, 200) : "download-threw", + }; + } +} + /** * Read a stored document's bytes server-side using the service key. * @@ -256,6 +368,58 @@ export async function uploadSecureDoc( } /** Best-effort removal of a secure object, for compensating a failed DB write. */ +/** + * Delete, and REFUSE to report success on anything less than a confirmed one. + * + * `removeSecureDoc` returns quietly when the ref is unusable or no storage + * client is configured, which is right for its best-effort callers (a leftover + * signature is not worth failing a contract over) but wrong for the receipt + * cleanup queue: "no client" there would mark an orphan resolved on a + * misconfigured deployment and lose it permanently. Same delete, honest result. + * + * Deliberately a separate export rather than a behaviour change to + * removeSecureDoc: several callers outside this feature do not guard it. + */ +/** + * Byte size of a stored object WITHOUT downloading it. + * + * The signed upload URL bypasses this server entirely, so the first time we see + * a two-step object is when something reads it — and reading it is exactly what + * must not happen for a 400 MB file. `list` with a search returns the metadata + * row, which is one small request regardless of the object's size. + * + * Returns null when the size cannot be determined (missing object, no client, + * an older storage API without metadata): callers treat that as "unknown" and + * fall through to their normal path rather than refusing on an absence. + */ +export async function secureObjectSize(storagePath: string): Promise { + const supabase = getSupabase(); + if (!supabase) return null; + const slash = storagePath.lastIndexOf("/"); + const dir = slash > 0 ? storagePath.slice(0, slash) : ""; + const name = slash > 0 ? storagePath.slice(slash + 1) : storagePath; + try { + const { data, error } = await supabase.storage + .from(SECURE_BUCKET) + .list(dir, { search: name, limit: 100 }); + if (error || !data) return null; + const match = data.find(entry => entry.name === name); + const size = (match?.metadata as { size?: unknown } | undefined)?.size; + return typeof size === "number" && Number.isFinite(size) ? size : null; + } catch { + return null; + } +} + +export async function removeSecureDocStrict(ref: string): Promise { + const path = secureRefPath(ref); + if (!path) throw new Error(`not a secure ref: ${String(ref).slice(0, 80)}`); + const supabase = getSupabase(); + if (!supabase) throw new Error("secure storage is not configured"); + const { error } = await supabase.storage.from(SECURE_BUCKET).remove([path]); + if (error) throw error; +} + export async function removeSecureDoc(ref: string): Promise { const path = secureRefPath(ref); if (!path) return; diff --git a/src/lib/supabase-storage-mock.ts b/src/lib/supabase-storage-mock.ts index 38e3307f8..166704b2d 100644 --- a/src/lib/supabase-storage-mock.ts +++ b/src/lib/supabase-storage-mock.ts @@ -84,6 +84,61 @@ function bucketApi(bucket: string) { return { data: [], error: null }; }, + /** + * Metadata listing — the ONLY way `receiptObjectSize` (bucket.ts) and + * `secureObjectSize` (secure-storage.ts) ask "is this object there, and + * how big is it" without downloading it. + * + * It was missing, and its absence was not inert: `from.list` was + * `undefined`, so the call THREW, and both callers classify a throw as + * TRANSIENT — "storage is having a moment", not "the object is gone". + * Every intake replay that reached the existence check therefore + * answered 503 instead of the 200/heal the caller had earned. A stub + * that omits a method does not omit a behaviour; it invents one. + * + * Shape follows storage-api: `name` is relative to `dir`, sub-folders + * come back once with `metadata: null`, and `search` is a PREFIX filter + * on that name (the SQL is `name ilike prefix || search || '%'`), not a + * substring one. + */ + async list( + dir: string, + opts?: { search?: string; limit?: number; offset?: number }, + ) { + const prefix = dir ? `${key(bucket, dir)}/` : `${bucket}/`; + // A folder must appear ONCE however many objects sit under it, and + // an object shadows nothing — so entries are keyed by name. + const entries = new Map(); + for (const [k, stored] of objects()) { + if (!k.startsWith(prefix)) continue; + const rest = k.slice(prefix.length); + if (!rest) continue; + const slash = rest.indexOf("/"); + if (slash === -1) entries.set(rest, stored); + else if (!entries.has(rest.slice(0, slash))) entries.set(rest.slice(0, slash), null); + } + const search = (opts?.search ?? "").toLowerCase(); + const names = [...entries.keys()] + .filter(name => !search || name.toLowerCase().startsWith(search)) + .sort(); + const offset = opts?.offset ?? 0; + const data = names + .slice(offset, offset + (opts?.limit ?? 100)) + .map(name => { + const stored = entries.get(name) ?? null; + return { + name, + id: stored ? `${bucket}/${dir ? `${dir}/` : ""}${name}` : null, + // `size` is the field both callers read; a folder has no + // metadata at all, which is how they tell the two apart. + metadata: stored + ? { size: stored.bytes.length, mimetype: stored.contentType } + : null, + }; + }); + return { data, error: null }; + }, + async download(path: string) { const stored = objects().get(key(bucket, path)); if (!stored) return { data: null, error: { message: "Object not found" } }; diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 2f09c43f0..b2a475517 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -25,6 +25,34 @@ export function isE2eStorageMockEnabled(): boolean { ); } +/** + * A client whose every request carries an AbortSignal. + * + * `getSupabase()` returns a process-wide singleton, and storage-js exposes no + * per-call signal — `download`/`upload`/`remove`/`list`/`createSignedUploadUrl` + * take no request options at all. So a caller that needs to bound a storage + * call gets its OWN client, built over a fetch that injects the signal. The + * construction is config only (no network, no auth round trip), which is what + * makes per-call cheap enough to be the rule rather than an optimisation. + * + * The e2e mock is returned unchanged: it never touches the network, so there + * is nothing to abort and building a real client would defeat the gate. + */ +export function getSupabaseWithSignal(signal: AbortSignal): SupabaseClient | null { + if (isE2eStorageMockEnabled()) return getSupabase(); + + const supabaseUrl = process.env.SUPABASE_URL || ""; + const supabaseKey = process.env.SUPABASE_SERVICE_KEY || ""; + if (!supabaseUrl || !supabaseKey) return null; + + return createClient(supabaseUrl, supabaseKey, { + global: { + fetch: (input: RequestInfo | URL, init?: RequestInit) => + fetch(input, { ...init, signal }), + }, + }); +} + export function getSupabase(): SupabaseClient | null { if (_initialized) return _supabase; _initialized = true; diff --git a/src/lib/time-expense-actions.ts b/src/lib/time-expense-actions.ts index 30479527b..115a1cfc2 100644 --- a/src/lib/time-expense-actions.ts +++ b/src/lib/time-expense-actions.ts @@ -13,8 +13,9 @@ import { } from "@/lib/time-expense-core"; import { dateInputInTimeZone, resolveCompanyTimeZone } from "@/lib/company-timezone"; import { resolveScheduleTaskIdForPunch } from "@/lib/punch-task-binding"; -import { toCompanyDayKey } from "@/lib/company-day"; -import { assertExpenseMutableOutsideQbo } from "@/lib/qbo-expense-guard"; +import { isReceiptUrlRef, resolveReceiptUrl } from "@/lib/receipt-intake/receipt-url"; +import { toCompanyDayKey } from "@/lib/company-day"; +import { assertExpenseMutableOutsideQbo } from "@/lib/qbo-expense-guard"; import { assertBulkDeletable, assertManualEntryDelete, @@ -334,22 +335,22 @@ export async function deleteExpense(id: string, projectId: string) { if (!hasPermission(user, "timeClock")) throw new Error("Forbidden"); const expense = await prisma.expense.findUnique({ where: { id }, - select: { - qbPurchaseId: true, - invoiceId: true, - invoicedAt: true, - estimate: { select: { projectId: true } }, - }, + select: { + qbPurchaseId: true, + invoiceId: true, + invoicedAt: true, + estimate: { select: { projectId: true } }, + }, + }); + if (!expense || expense.estimate.projectId !== projectId || !canAccessProject(user, expense.estimate.projectId)) { + throw new Error("Forbidden"); + } + assertExpenseMutableOutsideQbo(expense); + if (expense.invoiceId || expense.invoicedAt) throw new Error("Billed expenses cannot be deleted"); + + const deleted = await prisma.expense.deleteMany({ + where: { id, qbPurchaseId: null, invoiceId: null, invoicedAt: null }, }); - if (!expense || expense.estimate.projectId !== projectId || !canAccessProject(user, expense.estimate.projectId)) { - throw new Error("Forbidden"); - } - assertExpenseMutableOutsideQbo(expense); - if (expense.invoiceId || expense.invoicedAt) throw new Error("Billed expenses cannot be deleted"); - - const deleted = await prisma.expense.deleteMany({ - where: { id, qbPurchaseId: null, invoiceId: null, invoicedAt: null }, - }); if (deleted.count !== 1) throw new Error("Expense was billed while it was being deleted; refresh and try again"); revalidatePath(`/projects/${projectId}/time-expenses`); @@ -366,19 +367,19 @@ export async function deleteExpenses( const expenses = await prisma.expense.findMany({ where: { id: { in: ids } }, - select: { - id: true, - qbPurchaseId: true, - invoiceId: true, - invoicedAt: true, - estimate: { select: { projectId: true } }, - }, - }); - const accessible = expenses.filter( - e => e.estimate?.projectId && canAccessProject(user, e.estimate.projectId), - ); - for (const expense of accessible) assertExpenseMutableOutsideQbo(expense); - const allowed = accessible.filter(e => !e.invoiceId && !e.invoicedAt); + select: { + id: true, + qbPurchaseId: true, + invoiceId: true, + invoicedAt: true, + estimate: { select: { projectId: true } }, + }, + }); + const accessible = expenses.filter( + e => e.estimate?.projectId && canAccessProject(user, e.estimate.projectId), + ); + for (const expense of accessible) assertExpenseMutableOutsideQbo(expense); + const allowed = accessible.filter(e => !e.invoiceId && !e.invoicedAt); if (!allowed.length) return { deleted: 0 }; const allowedIds = allowed.map(e => e.id); @@ -387,12 +388,12 @@ export async function deleteExpenses( ); const result = await prisma.expense.deleteMany({ - where: { - id: { in: allowedIds }, - qbPurchaseId: null, - invoiceId: null, - invoicedAt: null, - }, + where: { + id: { in: allowedIds }, + qbPurchaseId: null, + invoiceId: null, + invoicedAt: null, + }, }); for (const projectId of projectIds) { @@ -470,7 +471,7 @@ export async function getTimeExpenseData(projectId: string) { orderBy: { startTime: "desc" }, }); - const expenses = await prisma.expense.findMany({ + const expenseRows = await prisma.expense.findMany({ where: { estimate: { projectId } }, include: { costCode: { select: { id: true, name: true, code: true } }, @@ -481,6 +482,17 @@ export async function getTimeExpenseData(projectId: string) { orderBy: { createdAt: "desc" }, }); + // `receiptUrl` is a stored REFERENCE for anything the receipt pipeline + // booked (`receipt-intake://…`), not a link — the tab renders it as an + // href, so it is resolved to a short-lived signed URL here. Legacy absolute + // URLs and data URLs come back unchanged. + const expenses = await Promise.all(expenseRows.map(async expense => ({ + ...expense, + receiptUrl: isReceiptUrlRef(expense.receiptUrl) + ? await resolveReceiptUrl(expense.receiptUrl) + : expense.receiptUrl, + }))); + const costCodes = await prisma.costCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, diff --git a/src/proxy.ts b/src/proxy.ts index 18b5cb494..3affad495 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -60,10 +60,19 @@ const MOBILE_AUTHENTICATED_ROUTE_PATTERNS = [ // with financialReports). Without this bypass NextAuth intercepts it first and // a headless Bearer check gets redirected to /login instead of its JSON. // Exact-match only — nothing else under /api/health/ inherits it. +// api/receipts/intake and api/receipts/intake//archived are the same shape +// (Receipt Pipeline v2, docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §3): the Apps +// Script forwarders and the nightly archive mirror self-authenticate with +// x-receipt-intake-secret and need a clean 401 rather than a /login redirect, +// and the mobile app reaches the same POST with a Bearer token. Both are listed +// EXACTLY — the [id]/archived form is spelled out rather than made a descendant +// wildcard, so a future /api/receipts/intake//anything route does not +// inherit the bypass before anyone has reviewed its gates. Everything else +// under /api/receipts (notably /api/receipts/parse) keeps the proxy boundary. // privacy / terms / account-deletion are static legal pages with no data access. // The app stores require them to be reachable by a logged-out reviewer, and Google // Play specifically requires a public account-deletion URL. -const PUBLIC_PROXY_BYPASS_PATTERN = /^\/(?:api\/health$|api\/health\/pipeline\/?$|api\/(?:auth|cron|twilio|webhook|payments|portal|integrations|mcp(?:\/|$)|version|pdf\/(?:estimates|invoices|change-orders)|sub-portal|mobile|selections\/(?:item-comments|ai-sort|link-schedule))(?:\/|$)|api\/office-tasks\/ingest\/?$|login(?:\/|$)|portal(?:\/|$)|sub-portal(?:\/|$)|share(?:\/|$)|privacy(?:\/|$)|terms(?:\/|$)|account-deletion(?:\/|$)|support(?:\/|$)|_next\/(?:static|image)(?:\/|$)|favicon\.ico$|.*\.(?:png|jpg|svg|webmanifest)$)/; +const PUBLIC_PROXY_BYPASS_PATTERN = /^\/(?:api\/health$|api\/health\/pipeline\/?$|api\/(?:auth|cron|twilio|webhook|payments|portal|integrations|mcp(?:\/|$)|version|pdf\/(?:estimates|invoices|change-orders)|sub-portal|mobile|selections\/(?:item-comments|ai-sort|link-schedule))(?:\/|$)|api\/office-tasks\/ingest\/?$|api\/receipts\/intake\/?$|api\/receipts\/intake\/start\/?$|api\/receipts\/intake\/[^/]+\/(?:archived|finalize)\/?$|login(?:\/|$)|portal(?:\/|$)|sub-portal(?:\/|$)|share(?:\/|$)|privacy(?:\/|$)|terms(?:\/|$)|account-deletion(?:\/|$)|support(?:\/|$)|_next\/(?:static|image)(?:\/|$)|favicon\.ico$|.*\.(?:png|jpg|svg|webmanifest)$)/; /** * The cookie that has to be present before a bypassed tree may dispatch a diff --git a/tests/apply-receipt-intake.test.ts b/tests/apply-receipt-intake.test.ts new file mode 100644 index 000000000..8025fdf4a --- /dev/null +++ b/tests/apply-receipt-intake.test.ts @@ -0,0 +1,1294 @@ +/** + * The rollout script and the committed migration must describe the SAME table. + * + * They are written twice on purpose — the script is what PRODUCTION gets + * (before the deploy that selects these columns), the migration is what a fresh + * CI/dev database gets — and nothing else in the repo notices when the two + * drift. CI's `migrations` job would eventually catch a difference by diffing + * against production, but only AFTER the script has been run there, which is + * exactly the wrong time to find out. + * + * Importing the script must NOT open a connection or read DATABASE_URL: all of + * that sits behind the isMainModule guard, the same shape apply-bank-ledger has. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + RECEIPT_BUCKET, + RECEIPT_BUCKET_FILE_SIZE_LIMIT, + RECEIPT_BUCKET_MIME_TYPES, + RECEIPT_INTAKE_STATES, + CONSTRAINT_LOOKUP_SQL, + columnDefaultMatches, + ensureReceiptBucket, + bucketIsAbsent, + verifyColumnDefaults, + expectedConstraints, + foreignKeyDrift, + maskUrl, + parseSizeLimit, + expectedColumns, + statements, + targetMatches, + targetHostMatches, + directDbHostForUrl, + verifyConstraints, + chooseTarget, + hostOf, + PROD_BASELINE_MIGRATION, + PROD_ENV_FILE, + PROD_POOLER_HOST_SUFFIX, + resolveTargetUrl, + targetLine, + verifyProdIdentity, + looksLikeSupabase, + projectRefOf, + PROJECT_REF_ENV, +} from "../scripts/apply-receipt-intake.mjs"; +import { RECEIPT_INTAKE_STATES as RUNTIME_STATES } from "../src/lib/receipt-intake/route-state"; + +const migrationSql = readFileSync( + path.join(__dirname, "..", "prisma", "migrations", "20260901000000_receipt_intake", "migration.sql"), + "utf8", +); + +/** Compare SQL by meaning, not by indentation: collapse whitespace, drop comments. */ +function normalize(sql: string): string { + return sql + .split(/\r?\n/) + .filter(line => !/^\s*--/.test(line)) + .join(" ") + .replace(/\s+/g, " ") + .replace(/\s*([(),])\s*/g, "$1") + .trim() + .toLowerCase(); +} + +test("every column the apply script creates is in the committed migration", () => { + const createTable = statements.find((s: string) => s.includes('CREATE TABLE IF NOT EXISTS "ReceiptIntake"')); + assert.ok(createTable, "the script must create the table"); + const columns = Array.from(createTable.matchAll(/"([a-zA-Z0-9]+)"\s+(TEXT|BOOLEAN|INTEGER|DOUBLE PRECISION|DATE|TIMESTAMP\(3\))/g)) + .map(m => m[1]); + assert.ok(columns.length >= 36, `expected the full column list, found ${columns.length}`); + const migration = normalize(migrationSql); + for (const column of columns) { + assert.ok(migration.includes(`"${column.toLowerCase()}"`), `migration.sql is missing "${column}"`); + } +}); + +test("the partial unique index is identical in both, predicate included", () => { + // This index IS the strong-dedup claim. A version of it without the + // predicate would reject legitimate re-reads of a quarantined row; a + // version with a different predicate would quarantine the wrong things. + const fromScript = statements.find((s: string) => s.includes("ReceiptIntake_dedupStrongKey_active_key")); + assert.ok(fromScript); + const expected = normalize(fromScript); + const fromMigration = migrationSql + .split(";") + .map(normalize) + .find(s => s.includes("receiptintake_dedupstrongkey_active_key")); + assert.equal(fromMigration, expected); + assert.ok(expected.includes(`where "dedupstrongkey" is not null and "state" not in('duplicate','void')`)); +}); + +/** + * The CHECK constraint's actual SEMANTICS: the ordered state list, and whether + * the block converges (replaces a stale definition) or merely creates. + * + * Token-presence — "does this file mention 'BOOKED' somewhere" — passed happily + * while the two files did DIFFERENT things with the constraint they both + * mention, which is exactly how they drifted. + */ +function checkSemantics(sql: string) { + const body = sql.slice(sql.indexOf("ReceiptIntake_state_check")); + return { + states: Array.from(body.matchAll(/'([A-Z_]{4,})'/g), m => m[1]) + .filter(token => (RECEIPT_INTAKE_STATES as string[]).includes(token)), + converges: /DROP CONSTRAINT "ReceiptIntake_state_check"/.test(body) + && /IS DISTINCT FROM wanted_def/.test(body), + scoped: /conrelid = '"ReceiptIntake"'::regclass/.test(body), + }; +} + +test("both files declare the SAME closed state set, and it matches the runtime one", () => { + // A state the CHECK constraint rejects but the code can produce is a + // guaranteed 500 on a document nobody can then see. + assert.deepEqual([...RUNTIME_STATES].sort(), [...RECEIPT_INTAKE_STATES].sort()); + const check = statements.find((s: string) => s.includes("ReceiptIntake_state_check")); + assert.ok(check, "the script must add the state CHECK constraint"); + + const fromScript = checkSemantics(check!); + const fromMigration = checkSemantics(migrationSql); + + // SEMANTIC PARITY, not "both mention the word". + assert.deepEqual(fromScript.states, fromMigration.states, "the same states, in the same order"); + assert.deepEqual( + [...new Set(fromScript.states)].sort(), + [...RECEIPT_INTAKE_STATES].sort(), + "and it is the whole closed set", + ); +}); + +test("BOTH paths converge on the wanted definition; neither only creates-if-absent", () => { + // A database that already carried an older state list kept it forever under + // "create only when absent", while the apply script corrected it in + // production — the same repo describing two different tables depending on + // which path built them. + const check = statements.find((s: string) => s.includes("ReceiptIntake_state_check"))!; + for (const [label, semantics] of [ + ["apply script", checkSemantics(check)], + ["migration.sql", checkSemantics(migrationSql)], + ] as const) { + assert.equal(semantics.converges, true, `${label} replaces a stale definition`); + assert.equal(semantics.scoped, true, `${label} scopes the lookup to this table`); + } + // And they agree on WHAT the wanted definition is, character for character: + // this string is compared against pg_get_constraintdef, so a single + // character of difference means one path replaces the constraint on every + // run while the other leaves it alone. + const wanted = (sql: string) => /wanted_def\s+TEXT\s*:=\s*('(?:[^']|'')*')/.exec(sql)?.[1] ?? null; + assert.ok(wanted(check), "the apply script declares a wanted definition"); + assert.equal(wanted(check), wanted(migrationSql)); +}); + +test("every FK and index in the script also exists in the migration", () => { + const names = statements.flatMap((sql: string) => + Array.from(sql.matchAll(/(?:INDEX IF NOT EXISTS|CONSTRAINT) "([^"]+)"/g), m => m[1]), + ); + assert.ok(names.length >= 9, `expected the full object list, found ${names.length}`); + for (const name of new Set(names)) { + assert.ok(migrationSql.includes(`"${name}"`), `migration.sql is missing ${name}`); + // PostgreSQL silently truncates past 63 bytes, which would make + // "IF NOT EXISTS" match a different object than the one intended. + assert.ok(Buffer.byteLength(name, "utf8") <= 63, `identifier "${name}" is too long`); + } +}); + +test("every statement is idempotent — the script is safe to re-run", () => { + for (const sql of statements) { + const guarded = + /CREATE TABLE IF NOT EXISTS/.test(sql) || + /CREATE (?:UNIQUE )?INDEX IF NOT EXISTS/.test(sql) || + /ALTER TABLE .* ADD COLUMN IF NOT EXISTS/.test(sql) || + // SET DEFAULT is idempotent by nature: setting a default that is + // already in place is a no-op, and there is no IF NOT EXISTS form + // of it to write. It repairs a table an earlier revision created + // with the wrong one. + /ALTER TABLE .* ALTER COLUMN .* SET DEFAULT/.test(sql) || + // Re-enabling RLS on a table that already has it is a no-op. + /ENABLE ROW LEVEL SECURITY/.test(sql) || + /IF NOT EXISTS \(SELECT 1 FROM pg_constraint/.test(sql) || + // The state CHECK is convergent rather than skip-if-present: it + // compares pg_get_constraintdef and only rewrites on a difference, + // so a second run is a no-op just the same. + (/pg_get_constraintdef/.test(sql) && /IS DISTINCT FROM/.test(sql)); + assert.ok(guarded, `not idempotent: ${sql.slice(0, 80)}`); + } +}); + +test("the target guard needs BOTH the database name and the host, EXACTLY", () => { + assert.equal(targetMatches({ db: "postgres", host: "10.0.0.5" }, "postgres", "10.0.0.5"), true); + assert.equal(targetMatches({ db: "postgres", host: "10.0.0.9" }, "postgres", "10.0.0.5"), false); + assert.equal(targetMatches({ db: "staging", host: "10.0.0.5" }, "postgres", "10.0.0.5"), false); + assert.equal(targetMatches(null, "postgres", "10.0.0.5"), false); + + // A substring match (which apply-bank-image.mjs uses) gets LOOSER the + // shorter the operator's input is: "1" would satisfy `host.includes` + // against 10.0.0.5, 172.16.1.1 and almost anything else. A guard whose + // whole job is to stop DDL landing on the wrong server must not have a + // degenerate case. + assert.equal(targetMatches({ db: "postgres", host: "10.0.0.5" }, "postgres", "1"), false); + assert.equal(targetMatches({ db: "postgres", host: "10.0.0.5" }, "postgres", "10.0.0.55"), false); + assert.equal(targetMatches({ db: "postgres", host: "" }, "postgres", "10.0.0.5"), false); +}); + +/** + * THE HOST CHECK COMPARED AN ADDRESS TO A NAME, SO IT COULD NEVER PASS. + * + * `host(inet_server_addr())` answers with the server's own IP -- through the + * Supabase pooler an IPv6 literal -- while --expect-host is the hostname out of + * DATABASE_URL. This script printed exactly that refusal against production. + * Every case below injects its resolver, so no test touches DNS. + */ +const POOLER_HOST = "aws-0-us-west-2.pooler.supabase.com"; +// The three A records aws-0-us-west-2.pooler.supabase.com published on +// 2026-09-04, and the address production's inet_server_addr() actually reports +// -- which is the AAAA of db.ghzdbzdnwjxazvmcefbh.supabase.co, NOT of the +// pooler. The pooler publishes no AAAA at all. +const POOLER_A = ["54.70.143.232", "35.160.209.8", "44.238.118.41"]; +const PROJECT_DB_HOST = "db.ghzdbzdnwjxazvmcefbh.supabase.co"; +const POOLER_V6 = "2600:1f13:838:6e45:7ee0:268:15b9:d263"; +const neverResolves = async () => { + throw new Error("the resolver must not be called for this case"); +}; + +test("pre-fix control: the exact comparison refuses the production pooler outright", () => { + // This IS the bug, asserted directly, and it is deliberately still true: + // targetMatches stays exact, and the fix is a second DNS-aware check rather + // than a loosening of this one. + assert.equal(targetMatches({ db: "postgres", host: POOLER_V6 }, "postgres", POOLER_HOST), false); +}); + +test("the host check resolves --expect-host and requires the connected address to be in that set", async t => { + const resolvesTo = (...addresses: string[]) => async (hostname: string) => { + assert.equal(hostname, POOLER_HOST); + return addresses; + }; + + await t.test("a hostname that resolves to the connected IPv6 is accepted", async () => { + assert.equal(await targetHostMatches(POOLER_V6, POOLER_HOST, resolvesTo("52.32.178.7", POOLER_V6)), "dns"); + }); + + await t.test("an IPv4-mapped answer is the same address written differently", async () => { + assert.equal(await targetHostMatches("::ffff:52.32.178.7", POOLER_HOST, resolvesTo("52.32.178.7")), "dns"); + }); + + await t.test("an address outside the resolved set is REFUSED", async () => { + assert.equal(await targetHostMatches("203.0.113.9", POOLER_HOST, resolvesTo("52.32.178.7", POOLER_V6)), null); + }); + + await t.test("an expected literal address needs no lookup at all", async () => { + // This is the shape scripts/ci-apply-receipt-intake-e2e.mjs passes: it + // reads inet_server_addr() itself and hands that literal back. + assert.equal(await targetHostMatches(POOLER_V6, POOLER_V6, neverResolves), "exact"); + assert.equal(await targetHostMatches("127.0.0.1", "127.0.0.1", neverResolves), "exact"); + }); + + await t.test("localhost is loopback by definition", async () => { + assert.equal(await targetHostMatches("127.0.0.1", "localhost", neverResolves), "loopback"); + assert.equal(await targetHostMatches("::1", "localhost", neverResolves), "loopback"); + assert.equal(await targetHostMatches("10.0.0.5", "localhost", neverResolves), null); + }); + + await t.test("no address at all (Unix socket) falls back to the URL's own hostname", async () => { + assert.equal(await targetHostMatches("", POOLER_HOST, neverResolves, POOLER_HOST), "unix-socket"); + // ...and that fallback may never rescue a host the URL disagrees with, + // nor a connection that DID report an address. + assert.equal(await targetHostMatches("", POOLER_HOST, neverResolves, "db.example.com"), null); + assert.equal(await targetHostMatches("", POOLER_HOST, neverResolves), null); + }); + + await t.test("an empty --expect-host matches nothing", async () => { + assert.equal(await targetHostMatches("10.0.0.5", "", neverResolves), null); + assert.equal(await targetHostMatches("", "", neverResolves, ""), null); + }); +}); + +/** + * THE PRODUCTION SHAPE, MEASURED RATHER THAN ASSUMED. + * + * Resolving --expect-host alone is NOT enough: the pooler name has no AAAA, and + * the address production reports belongs to the project's own database, because + * that is what answers behind Supavisor. The first case below is the exact + * production run, and the second is its control -- without the project host the + * same connection is still refused, so this is a second name being checked, not + * a blanket accept. + */ +test("the pooler's own name is not enough -- the project's database is what answers", async t => { + const zone: Record = { + [POOLER_HOST]: POOLER_A, + [PROJECT_DB_HOST]: [POOLER_V6], + }; + const resolve = async (hostname: string) => zone[hostname] ?? []; + + await t.test("production: pooler name + project db host accepts the reported IPv6", async () => { + assert.equal(await targetHostMatches(POOLER_V6, POOLER_HOST, resolve, "", PROJECT_DB_HOST), "project-db"); + }); + + await t.test("control: without the project host the same connection is REFUSED", async () => { + assert.equal(await targetHostMatches(POOLER_V6, POOLER_HOST, resolve, "", ""), null); + }); + + await t.test("another project's database does not rescue it", async () => { + const other = "db.someotherprojectref00.supabase.co"; + assert.equal(await targetHostMatches(POOLER_V6, POOLER_HOST, resolve, "", other), null); + }); + + await t.test("an address behind neither name is refused", async () => { + assert.equal(await targetHostMatches("203.0.113.9", POOLER_HOST, resolve, "", PROJECT_DB_HOST), null); + }); + + await t.test("a pooler A record still matches on the --expect-host name itself", async () => { + assert.equal(await targetHostMatches(POOLER_A[1], POOLER_HOST, resolve, "", PROJECT_DB_HOST), "dns"); + }); +}); + +test("the project's database host is derived from the URL, and only for a pooler URL", () => { + const ref = "ghzdbzdnwjxazvmcefbh"; + assert.equal( + directDbHostForUrl(`postgresql://postgres.${ref}:pw@aws-0-us-west-2.pooler.supabase.com:6543/postgres?pgbouncer=true`), + PROJECT_DB_HOST, + ); + // Not a pooler URL: nothing to widen to, and nothing is widened. + assert.equal(directDbHostForUrl(`postgresql://postgres.${ref}:pw@localhost:5432/postgres`), ""); + // No parseable project ref: refuse to guess one. + assert.equal(directDbHostForUrl("postgresql://postgres:pw@aws-0-us-west-2.pooler.supabase.com:6543/postgres"), ""); + assert.equal(directDbHostForUrl("not a url"), ""); +}); + +test("the live path uses the DNS-aware check and keeps the db name exact", () => { + const source = readFileSync(path.join(__dirname, "..", "scripts", "apply-receipt-intake.mjs"), "utf8"); + assert.match(source, /await targetHostMatches\(actual\.host, expectHost, lookupAddresses, urlHostname, directHost\)/); + assert.match(source, /const directHost = directDbHostForUrl\(url\);/); + assert.match(source, /String\(actual\.db \?\? ""\) !== String\(expectDb \?\? ""\)/); + // A falsy return is the refusal -- the exit must not be conditional on a label. + // 450 rather than the sibling script's 400 only because this file spells its + // em dashes "--", which makes the same refusal block a few characters longer. + assert.match(source, /if \(!hostMatch\) \{[\s\S]{0,450}?process\.exit\(1\);/); +}); + +test("the partial-index verification checks UNIQUE and the exact predicate", () => { + // Existence alone is not enough: a NON-unique index of the same name claims + // nothing, so every duplicate would sail through while the script reported + // success. + const source = readFileSync(path.join(__dirname, "..", "scripts", "apply-receipt-intake.mjs"), "utf8"); + assert.match(source, /CREATE UNIQUE INDEX/, "the verifier asserts uniqueness"); + assert.match(source, /indpred IS NOT NULL/, "the verifier asserts the index is PARTIAL"); + assert.ok( + source.includes(`WHERE \\(\\("dedupStrongKey" IS NOT NULL\\) AND \\(state <> ALL \\(ARRAY\\['DUPLICATE'::text, 'VOID'::text\\]\\)\\)\\)`), + "the verifier asserts the exact predicate, not merely that one exists", + ); +}); + +test("the state CHECK guard is scoped to the ReceiptIntake table", () => { + // pg_constraint names are not globally unique — conname alone would let an + // identically-named constraint on ANOTHER table satisfy the guard, and the + // CHECK would silently never be created. + const check = statements.find((s: string) => s.includes("ReceiptIntake_state_check")); + assert.match(check!, /conrelid = '"ReceiptIntake"'::regclass/); + assert.match(migrationSql, /conrelid = '"ReceiptIntake"'::regclass/); +}); + +test("the busyPasses column is ALSO added by an ALTER, so an earlier table upgrades", () => { + // CREATE TABLE IF NOT EXISTS is a no-op on a table that already exists, so + // a column added only to the CREATE would never reach a database where the + // rollout script had already run once. This is the whole reason the script + // is re-runnable. + const alter = statements.find((s: string) => /ADD COLUMN IF NOT EXISTS "busyPasses"/.test(s)); + assert.ok(alter, "the apply script must ALTER as well as CREATE"); + assert.match(migrationSql, /ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "busyPasses"/); +}); + +test("uploadLeaseNonce is created, ALTERed, and verified in all three places", () => { + // The adoption generation /start's discard CAS pins. A column that reached + // only the CREATE would never land on a database where the rollout script + // had already run once, and the CAS would then fence on a column that does + // not exist. + const createTable = statements.find((x: string) => x.includes('CREATE TABLE IF NOT EXISTS "ReceiptIntake"')); + assert.match(createTable!, /"uploadLeaseNonce"\s+TEXT/); + assert.ok( + statements.some((x: string) => /ADD COLUMN IF NOT EXISTS "uploadLeaseNonce" TEXT/.test(x)), + 'the apply script must ALTER as well as CREATE', + ); + assert.match(migrationSql, /ALTER TABLE "ReceiptIntake" ADD COLUMN IF NOT EXISTS "uploadLeaseNonce" TEXT/); + // NULLABLE on purpose: rows written before this column existed carry null, + // and /start stamps a value on every lease it issues from here on. + assert.ok(!/"uploadLeaseNonce"[^,]*NOT NULL/.test(createTable!)); + assert.ok(expectedColumns.ReceiptIntake.includes('uploadLeaseNonce'), 'and verification must look for it'); +}); + +test("STAGING is in the state set, and is the column DEFAULT", () => { + // A row is born STAGING: it exists, but its object is not in the bucket + // yet, so the worker's claim predicate must not be able to see it. + assert.ok(RECEIPT_INTAKE_STATES.includes("STAGING")); + const create = statements.find((s: string) => s.includes('CREATE TABLE IF NOT EXISTS "ReceiptIntake"')); + assert.match(create!, /"state"\s+TEXT NOT NULL DEFAULT 'STAGING'/); + assert.match(migrationSql, /"state" TEXT NOT NULL DEFAULT 'STAGING'/); +}); + +test("RLS is enabled on ReceiptIntake, in both files, and WITHOUT force", () => { + // Same shape as every other sensitive table here (apply-bank-ledger, + // apply-automation-events, apply-deposit-ingest-schema): ENABLE with no + // policies. The app connects as the owner/service role, which BYPASSES RLS, + // so reads and writes are unaffected — while anon and authenticated roles + // (a leaked anon key, a Supabase client someone wires up later) get nothing. + // + // FORCE is the trap: it applies RLS to the owner too, and with zero policies + // that denies everything. It would take the pipeline down silently, as + // empty result sets rather than errors. + assert.ok(statements.some((s: string) => /ALTER TABLE "ReceiptIntake" ENABLE ROW LEVEL SECURITY/.test(s))); + assert.match(migrationSql, /ALTER TABLE "ReceiptIntake" ENABLE ROW LEVEL SECURITY;/); + assert.ok(!statements.some((s: string) => /FORCE ROW LEVEL SECURITY/.test(s)), "never FORCE"); + assert.ok(!/FORCE ROW LEVEL SECURITY/.test(migrationSql), "never FORCE"); + + // And it must be recorded in the snapshot CI compares against production. + const snapshot = JSON.parse( + readFileSync(path.join(__dirname, "..", "prisma", "prisma-blind-spots.json"), "utf8"), + ); + const entry = snapshot.rlsTables.find((r: { name: string }) => r.name === "ReceiptIntake"); + assert.ok(entry, "ReceiptIntake missing from prisma-blind-spots.json rlsTables"); + assert.equal(entry.forced, false); +}); + +test("SHADOW_DONE is a real state everywhere, so the cutover write cannot fail", () => { + // The cutover UPDATE writes this value on every shadow-week row in one + // statement. If the CHECK constraint did not know it, the entire cutover + // would abort — inside the claim transaction, on the first live run. + assert.ok(RECEIPT_INTAKE_STATES.includes("SHADOW_DONE")); + const check = statements.find((s: string) => s.includes("ReceiptIntake_state_check")); + assert.match(check!, /'SHADOW_DONE'/); + assert.match(migrationSql, /'SHADOW_DONE'/); + const snapshot = JSON.parse( + readFileSync(path.join(__dirname, "..", "prisma", "prisma-blind-spots.json"), "utf8"), + ); + const entry = snapshot.checkConstraints.find((r: { name: string }) => r.name === "ReceiptIntake_state_check"); + assert.match(entry.def, /'SHADOW_DONE'::text/); +}); + +test("SHADOW_DONE stays in the strong-key active set", () => { + // A shadow-week row WAS booked (by v1), so its dedup key must keep + // quarantining a post-cutover resend of the same receipt. Only DUPLICATE and + // VOID — rows that represent nothing — drop out of the index. + const index = statements.find((s: string) => s.includes("ReceiptIntake_dedupStrongKey_active_key")); + assert.match(index!, /NOT IN \('DUPLICATE', 'VOID'\)/); + assert.ok(!/SHADOW_DONE/.test(index!), "SHADOW_DONE must NOT be excluded"); +}); + +test("the state CHECK is REPLACED when its definition drifts, not skipped", () => { + // `IF NOT EXISTS` alone is wrong for a set that GROWS: a database carrying + // the constraint from an earlier run keeps the OLD state list, so the first + // write of a newly-added state (SHADOW_DONE, at cutover, inside the claim + // transaction) fails and takes the whole cutover with it — while the script + // that exists to prevent exactly that reported "ok". + const check = statements.find((s: string) => s.includes("ReceiptIntake_state_check")); + assert.ok(check); + assert.match(check!, /pg_get_constraintdef/, "it compares the DEFINITION"); + assert.match(check!, /IS DISTINCT FROM/, "and reacts to a difference"); + assert.match(check!, /DROP CONSTRAINT "ReceiptIntake_state_check"/); + assert.match(check!, /ADD CONSTRAINT "ReceiptIntake_state_check"/); + // The wanted definition must name every state the code can produce, in + // pg_get_constraintdef's own rendering. + for (const state of RECEIPT_INTAKE_STATES) { + assert.ok(check!.includes(`''${state}''::text`), `wanted_def is missing ${state}`); + } +}); + +test("the wanted definition matches the snapshot CI compares against production", () => { + // Two renderings of the same constraint that disagree would make the + // apply script drop and re-add it on EVERY run. + const check = statements.find((s: string) => s.includes("ReceiptIntake_state_check"))!; + // [\s\S] rather than the /s flag — the tsconfig target predates it. + const wanted = check.match(/wanted_def\s+TEXT\s*:=\s*'([\s\S]+?)';/)![1].replace(/''/g, "'"); + const snapshot = JSON.parse( + readFileSync(path.join(__dirname, "..", "prisma", "prisma-blind-spots.json"), "utf8"), + ); + const recorded = snapshot.checkConstraints.find( + (r: { name: string }) => r.name === "ReceiptIntake_state_check", + ); + assert.equal(wanted, recorded.def); +}); + +test("verification asserts the CHECK ALLOWS every state, not just that it exists", () => { + assert.match(CONSTRAINT_LOOKUP_SQL, /pg_get_constraintdef\(oid\) AS def/, "verify reads the definition"); + const source = readFileSync(path.join(__dirname, "..", "scripts", "apply-receipt-intake.mjs"), "utf8"); + assert.match(source, /does not allow/, "and fails loudly naming what is missing"); +}); + +// ── A NAME IS NOT A CONSTRAINT (round-33 item 5) ─────────────────────────── + +/** + * A `pg_constraint` catalog that honours the scope in the SQL it is handed. + * + * `pg_constraint` is database-wide, so a lookup by `conname` alone is satisfied + * by a constraint of that name on ANY relation. This fake answers for a row on + * another table UNLESS the query actually scopes itself — which is how the + * scoping can be tested without a database. + */ +function catalog(rows: { name: string; table: string; def: string }[]) { + return async (sql: string, name: string) => { + const scoped = /conrelid = '"ReceiptIntake"'::regclass/.test(sql); + return rows + .filter(r => r.name === name && (!scoped || r.table === "ReceiptIntake")) + .map(r => ({ def: r.def })); + }; +} + +type ExpectedFk = { + name: string; kind: string; table: string; column: string; + references: string; referencedColumn: string; onDelete: string; onUpdate: string; +}; + +const expectedFks = (expectedConstraints as unknown as ExpectedFk[]).filter(c => c.kind === "fk"); + +/** What a CORRECT production database renders back, in pg's own shape. */ +const LIVE_CONSTRAINTS = [ + { + name: "ReceiptIntake_state_check", + table: "ReceiptIntake", + def: `CHECK ((state = ANY (ARRAY[${ + RECEIPT_INTAKE_STATES.map((s: string) => `'${s}'::text`).join(", ") + }])))`, + }, + ...expectedFks.map(c => ({ + name: c.name, + table: "ReceiptIntake", + def: `FOREIGN KEY ("${c.column}") REFERENCES "${c.references}"(${c.referencedColumn})` + + ` ON UPDATE ${c.onUpdate} ON DELETE ${c.onDelete}`, + })), +]; + +test("the control: a correct database verifies clean", async () => { + const { problems, notes } = await verifyConstraints(catalog(LIVE_CONSTRAINTS)); + assert.deepEqual(problems, []); + assert.equal(notes.length, expectedConstraints.length, "every constraint reported"); +}); + +test("a same-named constraint on ANOTHER table is drift, not a pass", async () => { + // The exact hole: pg_constraint is database-wide, so `WHERE conname = $1` + // was satisfied by a constraint of that name on any relation at all — and a + // database where ReceiptIntake never got its foreign keys still reported + // "verified 5 constraints". + const elsewhere = LIVE_CONSTRAINTS.map(c => + c.name === "ReceiptIntake_projectId_fkey" ? { ...c, table: "SomeOtherTable" } : c); + const { problems } = await verifyConstraints(catalog(elsewhere)); + assert.equal(problems.length, 1); + assert.match(problems[0], /ReceiptIntake_projectId_fkey missing on ReceiptIntake/); +}); + +test("a stale FK target is drift — the RIGHT name pointing at the WRONG parent", async () => { + const stale = LIVE_CONSTRAINTS.map(c => + c.name === "ReceiptIntake_costCodeId_fkey" + ? { ...c, def: 'FOREIGN KEY ("costCodeId") REFERENCES "Phase"(id) ON UPDATE CASCADE ON DELETE SET NULL' } + : c); + const { problems } = await verifyConstraints(catalog(stale)); + assert.equal(problems.length, 1); + assert.match(problems[0], /referenced table is Phase, want CostCode/); +}); + +test("ON DELETE CASCADE where SET NULL was written is drift", () => { + // The difference between "losing a project nulls a column" and "losing a + // project DELETES the audit trail of a booked receipt". + const expected = expectedFks.find(c => c.name === "ReceiptIntake_projectId_fkey")!; + const drift = foreignKeyDrift( + expected, + 'FOREIGN KEY ("projectId") REFERENCES "Project"(id) ON UPDATE CASCADE ON DELETE CASCADE', + ); + assert.match(drift!, /ON DELETE is CASCADE, want SET NULL/); +}); + +test("an FK with NO action clause reads as NO ACTION, never as 'unspecified'", () => { + // Postgres renders nothing at all for the SQL default, and NO ACTION is + // exactly the value that would BLOCK a project delete instead of nulling + // the column. Treating an absent clause as "fine" would wave that through. + const expected = expectedFks.find(c => c.name === "ReceiptIntake_expenseId_fkey")!; + const drift = foreignKeyDrift(expected, 'FOREIGN KEY ("expenseId") REFERENCES "Expense"(id)'); + assert.match(drift!, /ON DELETE is NO ACTION, want SET NULL/); + assert.match(drift!, /ON UPDATE is NO ACTION, want CASCADE/); +}); + +test("a missing constraint is still a failure, and names the table", async () => { + const without = LIVE_CONSTRAINTS.filter(c => c.name !== "ReceiptIntake_expenseId_fkey"); + const { problems } = await verifyConstraints(catalog(without)); + assert.equal(problems.length, 1); + assert.match(problems[0], /ReceiptIntake_expenseId_fkey missing on ReceiptIntake/); +}); + +test("the state CHECK is still verified by CONTENT, through the same path", async () => { + const narrowed = LIVE_CONSTRAINTS.map(c => + c.name === "ReceiptIntake_state_check" + ? { ...c, def: "CHECK ((state = ANY (ARRAY['RECEIVED'::text])))" } + : c); + const { problems } = await verifyConstraints(catalog(narrowed)); + assert.equal(problems.length, 1); + assert.match(problems[0], /does not allow: STAGING/); +}); + +test("every lookup is scoped to ReceiptIntake, and every expectation names it", () => { + assert.match(CONSTRAINT_LOOKUP_SQL, /conrelid = '"ReceiptIntake"'::regclass/); + assert.match(CONSTRAINT_LOOKUP_SQL, /conname = \$1/, "the NAME is the parameter, the table is not"); + for (const c of expectedConstraints) { + assert.equal(c.table, "ReceiptIntake", `${c.name} is scoped by the literal in the SQL`); + } +}); + +test("each expected FK matches the ALTER TABLE the script and the migration apply", () => { + // The expectation is only worth anything if it describes what is actually + // written. Both files are checked, so the verifier cannot drift away from + // either the production path or the CI one. + for (const fk of expectedFks) { + const shape = normalize( + `ADD CONSTRAINT "${fk.name}" FOREIGN KEY ("${fk.column}")` + + ` REFERENCES "${fk.references}"("${fk.referencedColumn}")` + + ` ON DELETE ${fk.onDelete} ON UPDATE ${fk.onUpdate}`, + ); + const statement = statements.find((s: string) => s.includes(`ADD CONSTRAINT "${fk.name}"`)); + assert.ok(statement, `${fk.name} is not in the script`); + assert.ok(normalize(statement!).includes(shape), `${fk.name}: script SQL disagrees with the expectation`); + assert.ok(normalize(migrationSql).includes(shape), `${fk.name}: migration.sql disagrees with the expectation`); + } +}); + +// ── The receipts bucket is provisioned, not assumed (round-13 item 3) ─────── + +test("the bucket policy in the script matches the one the code writes through", async () => { + // Two places name the same limits: the provisioner and the runtime module. + // If they drift, the runtime happily writes objects the bucket refuses (or, + // worse, accepts objects the runtime thinks are impossible). + const { RECEIPT_BUCKET_POLICY } = await import("../src/lib/receipt-intake/bucket"); + assert.equal(RECEIPT_BUCKET, RECEIPT_BUCKET_POLICY.name); + assert.equal(RECEIPT_BUCKET_FILE_SIZE_LIMIT, RECEIPT_BUCKET_POLICY.fileSizeLimit); + assert.deepEqual( + [...RECEIPT_BUCKET_MIME_TYPES].sort(), + [...RECEIPT_BUCKET_POLICY.allowedMimeTypes].sort(), + "the accepted formats and the bucket's allow-list are the same list", + ); + assert.equal(RECEIPT_BUCKET_POLICY.public, false); + + // ONE CEILING, and it is QuickBooks': a bucket that accepts more than QBO + // will attach stores receipts that are guaranteed to strand — the Purchase + // is created, the file is not on it, and the books look complete. The + // intake door, the bucket, the object check and the booking preflight are + // all the same number. + const { QBO_ATTACHMENT_MAX_BYTES, MAX_STORED_BYTES } = + await import("../src/lib/receipt-intake/intake-core"); + const { attachmentBlocker } = await import("../src/lib/receipt-intake/book"); + assert.equal(RECEIPT_BUCKET_FILE_SIZE_LIMIT, 8 * 1024 * 1024); + assert.equal(QBO_ATTACHMENT_MAX_BYTES, RECEIPT_BUCKET_FILE_SIZE_LIMIT); + assert.equal(MAX_STORED_BYTES, RECEIPT_BUCKET_FILE_SIZE_LIMIT); + // The booking preflight agrees at the boundary, in both directions. + assert.equal(attachmentBlocker("image/png", MAX_STORED_BYTES), null); + assert.equal(attachmentBlocker("image/png", MAX_STORED_BYTES + 1), `size:${MAX_STORED_BYTES + 1}`); +}); + +test("a missing bucket is CREATED private, with both limits", async () => { + const calls: Array<{ path: string; method: string; body: any }> = []; + const outcome = await ensureReceiptBucket("https://x.supabase.co", "key", async (_u: string, _k: string, path: string, init: any = {}) => { + calls.push({ path, method: init.method, body: init.body ? JSON.parse(init.body) : null }); + if (init.method === "GET") return { status: 404, ok: false, body: { error: "not found" } }; + return { status: 200, ok: true, body: { name: RECEIPT_BUCKET } }; + }); + assert.equal(outcome, "created"); + assert.equal(calls[0].method, "GET", "it looks before it creates"); + assert.equal(calls[1].body.public, false); + assert.equal(calls[1].body.file_size_limit, RECEIPT_BUCKET_FILE_SIZE_LIMIT); + assert.deepEqual(calls[1].body.allowed_mime_types, RECEIPT_BUCKET_MIME_TYPES); +}); + +/** + * THE SHAPE PRODUCTION ACTUALLY ANSWERS WITH, taken from a real run on + * 2026-09-04. Supabase Storage reports a missing bucket as HTTP 400 carrying a + * body that says 404, so the outer status alone is not the answer: the script + * threw "could not read bucket receipt-intake: 400 {...}" and never created it. + */ +const BUCKET_NOT_FOUND_BODY = { + statusCode: "404", + error: "Bucket not found", + message: "Bucket not found", + code: "NoSuchBucket", +}; + +test("a missing bucket answered as 400 + statusCode 404 is CREATED, not an error", async () => { + const calls: Array<{ path: string; method: string; body: any }> = []; + const outcome = await ensureReceiptBucket("https://x.supabase.co", "key", async (_u: string, _k: string, path: string, init: any = {}) => { + calls.push({ path, method: init.method, body: init.body ? JSON.parse(init.body) : null }); + // Verbatim: HTTP 400, and the 404 lives in the body. + if (init.method === "GET") return { status: 400, ok: false, body: BUCKET_NOT_FOUND_BODY }; + return { status: 200, ok: true, body: { name: RECEIPT_BUCKET } }; + }); + assert.equal(outcome, "created"); + assert.equal(calls.length, 2, "the create actually went out"); + assert.equal(calls[1].method, "POST"); + assert.equal(calls[1].path, "/bucket"); + // ...with the limits that only the bucket can enforce. + assert.equal(calls[1].body.public, false); + assert.equal(calls[1].body.file_size_limit, RECEIPT_BUCKET_FILE_SIZE_LIMIT); + assert.deepEqual(calls[1].body.allowed_mime_types, RECEIPT_BUCKET_MIME_TYPES); +}); + +test("CONTROL: a genuine failure is never read as an absent bucket", async () => { + // Without this the fix above would be indistinguishable from "create the + // bucket whenever the read fails", which would paper over a bad key, a + // permissions change or an outage by provisioning over the top of it. + for (const failure of [ + { status: 500, ok: false, body: { error: "Internal Server Error" } }, + { status: 403, ok: false, body: { statusCode: "403", error: "Unauthorized", code: "InvalidJWT" } }, + { status: 400, ok: false, body: { statusCode: "400", error: "Bad Request", code: "InvalidRequest" } }, + { status: 502, ok: false, body: null }, + ]) { + let writes = 0; + await assert.rejects( + () => ensureReceiptBucket("https://x.supabase.co", "key", async (_u: string, _k: string, _p: string, init: any = {}) => { + if (init.method !== "GET") { writes++; return { status: 200, ok: true, body: {} }; } + return failure; + }), + /could not read bucket/, + JSON.stringify(failure), + ); + assert.equal(writes, 0, `nothing may be created after ${failure.status}`); + } +}); + +test("absence is recognised by ANY of the spellings, and by nothing else", () => { + // The outer 404 (what the API used to be assumed to answer), and the three + // fields the measured 400 carries. Each on its own is enough, because a + // future API version dropping one of them must not turn the step back into + // a hard failure. + assert.equal(bucketIsAbsent({ status: 404, ok: false, body: { error: "not found" } }), true); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: BUCKET_NOT_FOUND_BODY }), true); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: { statusCode: 404 } }), true); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: { code: "NoSuchBucket" } }), true); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: { error: "Bucket not found" } }), true); + + // And nothing else is absence. + assert.equal(bucketIsAbsent({ status: 500, ok: false, body: { error: "Internal Server Error" } }), false); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: { statusCode: "400", code: "InvalidRequest" } }), false); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: null }), false); + assert.equal(bucketIsAbsent({ status: 400, ok: false, body: { raw: "Bucket not found" } }), false); + // A SUCCESSFUL read is never absence, whatever the body happens to say. + assert.equal(bucketIsAbsent({ status: 200, ok: true, body: BUCKET_NOT_FOUND_BODY }), false); + assert.equal(bucketIsAbsent(null), false); +}); + +test("an existing bucket with the right policy is VERIFIED, and nothing is written", async () => { + let writes = 0; + const outcome = await ensureReceiptBucket("https://x.supabase.co", "key", async (_u: string, _k: string, _p: string, init: any = {}) => { + if (init.method !== "GET") { writes++; return { status: 200, ok: true, body: {} }; } + return { + status: 200, ok: true, + body: { + name: RECEIPT_BUCKET, public: false, + file_size_limit: RECEIPT_BUCKET_FILE_SIZE_LIMIT, + allowed_mime_types: RECEIPT_BUCKET_MIME_TYPES, + }, + }; + }); + assert.equal(outcome, "verified"); + assert.equal(writes, 0, "re-running provisions nothing"); +}); + +test("a DIFFERENT limit is a hard failure, never a silent correction", async () => { + // Overwriting a limit somebody set deliberately is how a 400 MB upload + // becomes possible again next quarter. The operator has to see it. + const cases: Array<[Record, RegExp]> = [ + [{ file_size_limit: 50 * 1024 * 1024 }, /file_size_limit/], + [{ file_size_limit: "50MB" }, /file_size_limit/], + [{ public: true }, /PUBLIC/], + [{ allowed_mime_types: null }, /allowed_mime_types is unset/], + [{ allowed_mime_types: ["image/png"] }, /missing/], + [{ allowed_mime_types: [...RECEIPT_BUCKET_MIME_TYPES, "application/zip"] }, /unexpected/], + ]; + for (const [override, expected] of cases) { + const body = { + name: RECEIPT_BUCKET, public: false, + file_size_limit: RECEIPT_BUCKET_FILE_SIZE_LIMIT, + allowed_mime_types: RECEIPT_BUCKET_MIME_TYPES, + ...override, + }; + await assert.rejects( + () => ensureReceiptBucket("https://x.supabase.co", "key", async () => ({ status: 200, ok: true, body })), + expected, + JSON.stringify(override), + ); + } +}); + +test("Supabase's file_size_limit is read in either shape", () => { + // It comes back as a byte count from some API versions and as "15MB" from + // others; reading only one of those would fail a correct bucket. + assert.equal(parseSizeLimit(15728640), 15728640); + assert.equal(parseSizeLimit("15728640"), 15728640); + assert.equal(parseSizeLimit("15MB"), 15728640); + assert.equal(parseSizeLimit("15 mb"), 15728640); + assert.equal(parseSizeLimit(null), null); + assert.equal(parseSizeLimit("enormous"), null); +}); + +test("a storage read failure stops the run rather than assuming the bucket is fine", async () => { + await assert.rejects( + () => ensureReceiptBucket("https://x.supabase.co", "key", async () => ({ + status: 500, ok: false, body: { error: "boom" }, + })), + /could not read bucket/, + ); +}); + +// ── The DATABASE_URL redactor (Codex round-17 item 2) ───────────────────── +// +// `maskUrl` is printed by the apply script's own preflight, so whatever it +// returns ends up in terminal scrollback and in the tickets operators paste +// it into. The regex it replaces — `/:[^:@]*@/` -> `:****@` — matched only the +// LAST colon-delimited run before the `@`, so a password containing a literal +// colon had its first half printed in clear. + +test("a password containing a colon is FULLY redacted", () => { + // The exact leak: `pa:ss` printed as `pa:****`, exposing `pa`. + const masked = maskUrl("postgresql://appuser:pa:ss@db.example.com:5432/probuild"); + assert.ok(!masked.includes("pa:ss"), masked); + assert.ok(!masked.includes(":pa"), `no fragment of the password survives: ${masked}`); + assert.ok(!masked.includes("ss@"), masked); + // Still useful: the host, port and database are what the operator is + // checking against --expect-db / --expect-host. + assert.ok(masked.includes("db.example.com"), masked); + assert.ok(masked.includes("5432"), masked); + assert.ok(masked.includes("probuild"), masked); + + // PRE-FIX CONTROL: the old regex leaks on this exact input, so this test + // cannot pass for the implementation it replaced. + const oldRegex = "postgresql://appuser:pa:ss@db.example.com:5432/probuild" + .replace(/:[^:@]*@/, ":****@"); + assert.ok(oldRegex.includes(":pa"), "the old redactor printed the first half"); +}); + +test("a percent-encoded @ in the password does not end the userinfo early", () => { + // `@` is legal inside a password when encoded, and a regex anchored on the + // first or last `@` gets the boundary wrong either way. + const masked = maskUrl("postgresql://appuser:p%40ss%3Aword@db.example.com:6543/probuild"); + assert.ok(!masked.includes("p%40ss"), masked); + assert.ok(!masked.includes("word"), masked); + assert.ok(masked.includes("db.example.com"), masked); +}); + +test("the USERNAME goes too — an account name is a credential", () => { + const masked = maskUrl("postgresql://postgres.abcdefgh:secret@aws-0-us-west-2.pooler.supabase.com:6543/postgres"); + assert.ok(!masked.includes("secret"), masked); + assert.ok(!masked.includes("postgres.abcdefgh"), masked); + assert.ok(masked.includes("pooler.supabase.com"), masked); +}); + +test("an UNPARSEABLE url is never echoed, not even in part", () => { + // There is nothing safe to show: any substring of a malformed string could + // be the password, so a redactor that prints "the bit I could not parse" + // leaks the thing it exists to hide. + for (const bad of ["not a url at all", "://user:pw@host", "", "postgres:/missing-slash@host"]) { + const masked = maskUrl(bad); + assert.equal(masked, "", bad); + } + // `postgresql://` and `postgres:/x@y` both PARSE — WHATWG accepts a bare + // scheme, and the second as an opaque path whose `@` is not a userinfo + // boundary at all. Neither has a host, which is how the redactor knows it + // could not locate the credentials, so both take the placeholder rather + // than being echoed on the guess that their `@` is harmless. + assert.equal(maskUrl("postgresql://"), ""); +}); + +test("a url with no credentials is passed through readably", () => { + // The control: redaction must not mangle a URL that has nothing to hide, + // or the preflight line stops being useful for its actual purpose. + const masked = maskUrl("postgresql://db.example.com:5432/probuild?sslmode=require"); + assert.ok(masked.includes("db.example.com"), masked); + assert.ok(masked.includes("sslmode=require"), masked); + assert.ok(!masked.includes("***"), masked); +}); + +// ── The upgrade path repairs the state default (round-18 item 4) ────────── +// +// `CREATE TABLE IF NOT EXISTS` carries DEFAULT 'STAGING' and is a no-op on an +// existing table, so a ReceiptIntake created by an earlier Phase-1 revision +// keeps DEFAULT 'RECEIVED'. Adding columns cannot fix that. Every row inserted +// without an explicit state then skipped STAGING and became claimable by the +// worker before its object existed — precisely what the two-step upload exists +// to prevent — and the verify reported clean, because it read column NAMES and +// the column was present either way. + +test("the upgrade path SETS the default, in both the script and the migration", () => { + const repair = `ALTER TABLE "ReceiptIntake" ALTER COLUMN "state" SET DEFAULT 'STAGING'`; + assert.ok( + statements.some(s => s.includes(repair)), + "the apply script repairs it", + ); + const migration = readFileSync( + path.join(__dirname, "..", "prisma/migrations/20260901000000_receipt_intake/migration.sql"), + "utf8", + ); + assert.ok(migration.includes(`${repair};`), "and so does the migration's upgrade section"); + // It must live in the UPGRADE section — after the CREATE TABLE, which is + // the statement that is a no-op on an existing table. + assert.ok( + migration.indexOf(repair) > migration.indexOf("CREATE TABLE IF NOT EXISTS \"ReceiptIntake\""), + "after the create, where the upgrade statements are", + ); +}); + +test("the verify reads DEFAULTS, and reports drift", async () => { + // A name check cannot see this, which is why it reported clean while the + // default was wrong. + const wrong = await verifyColumnDefaults(async () => [{ column_default: "'RECEIVED'::text" }]); + assert.equal(wrong.problems.length, 1); + assert.match(wrong.problems[0], /ReceiptIntake\.state default is 'RECEIVED'::text, expected 'STAGING'/); + + // PRE-FIX CONTROL: the old verify only asked for column NAMES, and + // `state` is present in both shapes — so it passed. + assert.ok(expectedColumns.ReceiptIntake.includes("state"), "the column is there either way"); + + const right = await verifyColumnDefaults(async () => [{ column_default: "'STAGING'::text" }]); + assert.deepEqual(right.problems, []); + assert.equal(right.notes.length, 1); + + // A column with NO default at all is drift too, not an absence to shrug at. + // THE TABLE AND COLUMN ARE BOUND PARAMETERS, not SQL text. This is not + // pedantry: the DB-gated probe in receipt-intake-claim-db.test.ts pointed + // itself at a stand-in table by rewriting the SQL string, which substituted + // nothing, so the check silently ran against the REAL table and reported + // clean. Anything redirecting this query must rewrite the ARGS. + const calls: { sql: string; args: unknown[] }[] = []; + await verifyColumnDefaults(async (sql: string, ...args: unknown[]) => { + calls.push({ sql, args }); + return [{ column_default: "'STAGING'::text" }]; + }); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].args, ["ReceiptIntake", "state"], "passed as $1 and $2"); + assert.ok(!calls[0].sql.includes("ReceiptIntake"), "and NOT interpolated into the SQL"); + + const none = await verifyColumnDefaults(async () => [{ column_default: null }]); + assert.equal(none.problems.length, 1); + assert.match(none.problems[0], /default is \(none\)/); +}); + +test("the default comparison ignores how Postgres echoes the cast", () => { + // `information_schema` renders a text literal as `'STAGING'::text`; the + // bare literal is the same default. Comparing raw strings would make the + // check fail on a correct database, which is worse than not checking. + assert.equal(columnDefaultMatches("'STAGING'::text", "'STAGING'::text"), true); + assert.equal(columnDefaultMatches("'STAGING'", "'STAGING'::text"), true); + assert.equal(columnDefaultMatches("'STAGING'::character varying", "'STAGING'::text"), true); + assert.equal(columnDefaultMatches("'RECEIVED'::text", "'STAGING'::text"), false); + assert.equal(columnDefaultMatches(null, "'STAGING'::text"), false); + assert.equal(columnDefaultMatches(undefined, "'STAGING'::text"), false); +}); + +// ── THE SCRIPT HAS TO PROVE WHICH DATABASE IT IS TALKING TO ─────────────── +// +// It used to resolve its URL from `process.env.DATABASE_URL` FIRST. A +// developer with a local one exported in their shell could run this, watch +// every statement report ok against their own Postgres, and merge believing +// production had been migrated -- there was no line in the output that said +// otherwise. `--target prod` is now required, it reads .env.production.local +// and nothing else, and the run prints a redacted target line before the +// first statement. + +test("an ambient DATABASE_URL is NOT a target: no flag, no run", () => { + // The exact shape of the accident: a local URL in the environment and an + // otherwise complete command line. + const argv = [ + "node", "scripts/apply-receipt-intake.mjs", + "--yes", "--expect-db", "postgres", "--expect-host", "10.0.0.5", + ]; + const refused = chooseTarget(argv); + assert.equal(refused.ok, false); + assert.match(String((refused as { reason?: string }).reason), /--target prod/); + assert.match(String((refused as { reason?: string }).reason), /ambient DATABASE_URL is NOT a target/); + + // A wrong target is refused too, rather than silently meaning prod. + const staging = chooseTarget(["node", "s.mjs", "--target", "staging", "--yes"]); + assert.equal(staging.ok, false); + assert.match(String((staging as { reason?: string }).reason), /Targets are prod and ci/); + + // ...and a bare `--target` with nothing after it. + assert.equal(chooseTarget(["node", "s.mjs", "--target"]).ok, false); + + // CONTROL: the real invocation is accepted. + assert.deepEqual(chooseTarget(["node", "s.mjs", "--target", "prod", "--yes"]), { + ok: true, + target: "prod", + }); + // ...and so is the CI one, which is a different database entirely. + assert.deepEqual(chooseTarget(["node", "s.mjs", "--target", "ci", "--yes"]), { + ok: true, + target: "ci", + }); +}); + +test("--target ci takes the ambient URL, and REFUSES a Supabase one", () => { + // The CI driver builds a throwaway database and runs the real script + // against it, so this target has to read the ambient URL -- and it must + // never be able to reach a real project through that door. + const before = process.env.DATABASE_URL; + try { + process.env.DATABASE_URL = "postgresql://probuild:probuild@localhost:5432/probuild_apply"; + const ci = resolveTargetUrl("ci"); + assert.match(ci.url, /localhost/); + assert.match(ci.from, /--target ci/); + + // A pooler URL, a direct URL -- both refused. + for (const url of [ + "postgresql://postgres.abc:pw@aws-0-us-west-2.pooler.supabase.com:6543/postgres", + "postgresql://postgres:pw@db.ghzdbzdnwjxazvmcefbh.supabase.co:5432/postgres", + ]) { + process.env.DATABASE_URL = url; + assert.throws(() => resolveTargetUrl("ci"), /REFUSING/, url); + assert.equal(looksLikeSupabase(url), true); + } + + delete process.env.DATABASE_URL; + assert.throws(() => resolveTargetUrl("ci"), /DATABASE_URL is required/); + } finally { + if (before === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = before; + } + + // And a local URL is NOT mistaken for Supabase. + assert.equal(looksLikeSupabase("postgresql://u:p@localhost:5432/db"), false); +}); + +test("the ci identity check proves the target is NOT production", async () => { + const query = async () => [{ db: "probuild_apply", host: "" }] as unknown[]; + + const ok = await verifyProdIdentity( + query, + "localhost", + "", + undefined, + "ci", + ); + assert.deepEqual( + ok.problems, + [], + "no baseline row and no project ref are required of a throwaway database", + ); + assert.match(ok.line, /project=\(ci\)/); + assert.match(ok.line, /database=probuild_apply/); + + // Pointed at Supabase it refuses, even though every other fact checks out. + const wrong = await verifyProdIdentity( + query, + "aws-0-us-west-2.pooler.supabase.com", + "", + undefined, + "ci", + ); + assert.equal(wrong.problems.length, 1); + assert.match(wrong.problems[0], /REFUSING: --target ci was pointed at/); +}); + +test("--target prod reads .env.production.local, and IGNORES the environment", () => { + // The override is the point: preferring an ambient value is what let a + // local database be mistaken for production. + const before = process.env.DATABASE_URL; + process.env.DATABASE_URL = "postgresql://dev:dev@localhost:5432/probuild_dev"; + try { + const resolved = resolveTargetUrl( + "prod", + () => 'DATABASE_URL="postgresql://u:p@aws-0-us-west-2.pooler.supabase.com:6543/postgres?pgbouncer=true"\nOTHER=1\n', + () => true, + ); + assert.equal(resolved.from, PROD_ENV_FILE); + assert.match(resolved.url, /pooler\.supabase\.com/); + assert.ok(!resolved.url.includes("localhost"), "the ambient URL never wins"); + } finally { + if (before === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = before; + } + + // A missing file is a refusal with a remedy, never a fallback. + assert.throws( + () => resolveTargetUrl("prod", () => "", () => false), + /not found/, + ); + // A file with no DATABASE_URL is a refusal too. + assert.throws( + () => resolveTargetUrl("prod", () => "NEXTAUTH_SECRET=x\n", () => true), + /DATABASE_URL not found/, + ); +}); + +test("the identity check needs the POOLER host, the PROJECT and the BASELINE", async () => { + const PROD = "ghzdbzdnwjxazvmcefbh"; + const rows = { + identity: [{ db: "postgres", host: "10.0.0.5" }], + baseline: [{ migration_name: PROD_BASELINE_MIGRATION }], + }; + const query = async (sql: string) => + (/current_database/.test(sql) ? rows.identity : rows.baseline) as unknown[]; + const host = `aws-0-us-west-2${PROD_POOLER_HOST_SUFFIX}`; + + const good = await verifyProdIdentity(query, host, PROD, PROD); + assert.deepEqual(good.problems, [], "right host, right project, baseline present"); + assert.match(good.line, new RegExp(`project=${PROD}`)); + + // THE CASE HOST + DATABASE + BASELINE CANNOT SEE. Supabase's pooler + // hostnames are shared regionally and every Supabase database is called + // `postgres`, so a staging clone migrated off the same baseline presents + // an IDENTICAL host, name and migration row. Only the project ref differs. + const clone = await verifyProdIdentity(query, host, "stagingclone123456ab", PROD); + assert.equal(clone.problems.length, 1); + assert.match(clone.problems[0], /is not ghzdbzdnwjxazvmcefbh: same pooler host, different project/); + + // An UNSET variable is a refusal, not a skip: a check that turns itself + // off when its input is missing is the check not existing. + const unset = await verifyProdIdentity(query, host, PROD, undefined); + assert.equal(unset.problems.length, 1); + assert.match(unset.problems[0], new RegExp(`${PROJECT_REF_ENV} is not set`)); + + // A URL whose username carries no ref cannot satisfy it either. + const noRef = await verifyProdIdentity(query, host, "", PROD); + assert.equal(noRef.problems.length, 1); + assert.match(noRef.problems[0], /no project ref/); + + // A local host is refused even when the database is called `postgres`. + const local = await verifyProdIdentity(query, "localhost", PROD, PROD); + assert.equal(local.problems.length, 1); + assert.match(local.problems[0], /not a \.pooler\.supabase\.com pooler host/); + + // And a pooler host WITHOUT the baseline row is refused: migration history + // is the fact a look-alike database cannot fake. + rows.baseline = []; + const noBaseline = await verifyProdIdentity(query, host, PROD, PROD); + assert.equal(noBaseline.problems.length, 1); + assert.match(noBaseline.problems[0], /no 20260814000000_baseline_production row/); + + // A _prisma_migrations table that does not exist at all is the same answer, + // not a crash. + const noTable = await verifyProdIdentity( + async (sql: string) => { + if (/current_database/.test(sql)) return rows.identity as unknown[]; + throw new Error('relation "_prisma_migrations" does not exist'); + }, + host, + PROD, + PROD, + ); + assert.equal(noTable.problems.length, 1); + assert.match(noTable.problems[0], /this is not production/); +}); + +test("the project ref comes out of the URL USERNAME, which is where it lives", () => { + assert.equal( + projectRefOf("postgresql://postgres.ghzdbzdnwjxazvmcefbh:pw@aws-0-us-west-2.pooler.supabase.com:6543/postgres"), + "ghzdbzdnwjxazvmcefbh", + ); + // Percent-encoding in the userinfo is normal and must not hide the ref. + assert.equal( + projectRefOf("postgresql://postgres.abc123:p%40ss%3Aword@aws-0-us-west-2.pooler.supabase.com:6543/postgres"), + "abc123", + ); + // A direct (non-pooler) URL has a bare username and so carries no ref. + assert.equal(projectRefOf("postgresql://postgres:pw@db.example.supabase.co:5432/postgres"), ""); + // Nothing parseable, nothing claimed. + assert.equal(projectRefOf("not a url"), ""); + assert.equal(projectRefOf(""), ""); +}); + +test("the TARGET LINE names host, database and baseline -- and no credentials", async () => { + const line = targetLine({ + host: "aws-0-us-west-2.pooler.supabase.com", + database: "postgres", + projectRef: "ghzdbzdnwjxazvmcefbh", + baseline: true, + }); + assert.equal( + line, + "TARGET host=aws-0-us-west-2.pooler.supabase.com project=ghzdbzdnwjxazvmcefbh" + + " database=postgres baseline=present", + ); + // It is built from a PARSED hostname and the name the SERVER reported, so + // there is no path by which a password reaches it. (The URL log line's own + // redaction is covered by the maskUrl tests above.) + const secretish = "postgresql://postgres.abc123:pa:ss@aws-0-us-west-2.pooler.supabase.com:6543/postgres"; + assert.equal(hostOf(secretish), "aws-0-us-west-2.pooler.supabase.com"); + const built = targetLine({ + host: hostOf(secretish), + database: "postgres", + projectRef: projectRefOf(secretish), + baseline: false, + }); + assert.ok(!built.includes("pa:ss"), "no credential can ride in on the host"); + // The PROJECT REF is published on purpose -- it is a public identifier, + // the same one that appears in the Supabase URL -- but the password half + // of the userinfo never is. + assert.match(built, /project=abc123/); + assert.ok(!built.includes("postgres.abc123:"), "the username is not echoed verbatim"); + assert.match(built, /baseline=MISSING/); + assert.equal(hostOf("not a url at all"), "", "an unparseable URL yields no host, never a fragment"); +}); + +test("main() refuses BEFORE it builds a client, and prints the target BEFORE any DDL", () => { + // Order is the property, and it is asserted on the shipped source: a check + // that runs after the first ALTER has already changed the wrong database. + const script = readFileSync(path.join(__dirname, "..", "scripts", "apply-receipt-intake.mjs"), "utf8"); + const main = script.slice(script.indexOf("async function main()")); + const chooseAt = main.indexOf("chooseTarget(process.argv)"); + const clientAt = main.indexOf("new PrismaClient("); + const identityAt = main.indexOf("await verifyProdIdentity("); + const printAt = main.indexOf("console.log(identity.line)"); + const ddlAt = main.indexOf("await prisma.$executeRawUnsafe(sql)"); + + assert.ok(chooseAt > 0, "main asks for a target"); + assert.ok(chooseAt < clientAt, "and refuses before a client is even built"); + assert.ok(clientAt < identityAt && identityAt < ddlAt, "identity is proven before any DDL"); + assert.ok(printAt > 0 && printAt < ddlAt, "and the target line is printed before it too"); + + // --dry-run reports the same target line and runs nothing. + assert.match(main, /if \(dryRun\) \{/); + const dryAt = main.indexOf("if (dryRun) {"); + assert.ok(printAt < dryAt && dryAt < ddlAt, "a dry run has already printed the target, and returns before the DDL"); + + // The old ambient resolver is GONE, not merely unused. + assert.ok(!script.includes("resolveDatabaseUrl"), "no ambient-first resolver survives"); + // A CALL, not a mention: the doc comments name the ambient variable they + // stopped reading, so comment lines are stripped first. + const code = script + .split(/\r?\n/) + .filter(line => !line.trim().startsWith("*") && !line.trim().startsWith("//")) + .join("\n"); + // EXACTLY ONE reader, and it is inside the `--target ci` branch -- a + // throwaway database the caller had to name, on a URL that is refused if + // it looks like Supabase. The prod path reads .env.production.local and + // nothing else. + const ambient = code.split("process.env.DATABASE_URL").length - 1; + assert.equal(ambient, 1, "one ambient read, in the ci branch"); + const ciBranch = code.slice( + code.indexOf('if (target === "ci")'), + code.indexOf('if (target !== "prod")'), + ); + assert.match(ciBranch, /const url = process\.env\.DATABASE_URL;/); + assert.match(ciBranch, /looksLikeSupabase\(url\)/, "and it refuses a Supabase URL"); +}); + +// -- THE REAL PRODUCTION URL SHAPE MUST PASS THE GUARD -------------------- +// +// A guard that refuses the only URL it will ever be pointed at is not a +// guard, it is an outage -- and nothing else in this suite would notice, +// because every other case feeds it a hand-written host. `new URL(u).host` +// INCLUDES the port (`aws-0-us-west-2.pooler.supabase.com:6543`), so a suffix +// test against `host` rejects the transaction pooler every time; `hostname` +// does not. This drives the whole identity check with the exact string +// production carries. + +test("the REAL pooler URL passes the guard, and a wrong project ref does not", async () => { + const PROD_REF = "ghzdbzdnwjxazvmcefbh"; + const PROD_URL = + `postgresql://postgres.${PROD_REF}:s3cr3t-p%40ss@aws-0-us-west-2.pooler.supabase.com:6543` + + "/postgres?pgbouncer=true&connection_limit=1"; + + // The port is not part of the hostname, which is what the pooler check reads. + assert.equal(hostOf(PROD_URL), "aws-0-us-west-2.pooler.supabase.com"); + assert.ok( + !hostOf(PROD_URL).includes(":"), + "a host WITH the port would fail the .pooler.supabase.com suffix test", + ); + assert.equal(projectRefOf(PROD_URL), PROD_REF); + + const query = async (sql: string) => (/current_database/.test(sql) + ? [{ db: "postgres", host: "10.0.0.5" }] + : [{ migration_name: PROD_BASELINE_MIGRATION }]) as unknown[]; + + const ok = await verifyProdIdentity(query, hostOf(PROD_URL), projectRefOf(PROD_URL), PROD_REF); + assert.deepEqual(ok.problems, [], "the real production URL is admitted"); + assert.match(ok.line, new RegExp(`project=${PROD_REF}`)); + assert.match(ok.line, /baseline=present/); + // The redacted line carries no credential from that URL. + assert.ok(!ok.line.includes("s3cr3t"), "no password"); + assert.ok(!ok.line.includes("p%40ss"), "not even percent-encoded"); + + // A DIFFERENT project on the SAME pooler host, with the same database name + // and the same baseline row -- the staging-clone case -- is refused. + const clone = + `postgresql://postgres.stagingclone000000:pw@aws-0-us-west-2.pooler.supabase.com:6543` + + "/postgres?pgbouncer=true"; + const refused = await verifyProdIdentity(query, hostOf(clone), projectRefOf(clone), PROD_REF); + assert.equal(refused.problems.length, 1); + assert.match(refused.problems[0], /same pooler host, different project/); +}); + +test("the claims table is in BOTH the migration and the script", () => { + // The invariant that makes a second live claim impossible is a PRIMARY KEY, + // so it only exists if the table does -- in both places, or a fresh CI + // database and a production one disagree about whether the guard is there. + for (const [label, sql] of [["migration", migrationSql], ["script", statements.join(";")]] as const) { + assert.match(sql, /CREATE TABLE IF NOT EXISTS "ReceiptObjectClaim"/, label); + assert.match(sql, /"storagePath" TEXT NOT NULL/, label); + assert.match(sql, /CONSTRAINT "ReceiptObjectClaim_pkey" PRIMARY KEY \("storagePath"\)/, `${label}: one row per path`); + } +}); diff --git a/tests/apply-scripts-inert-on-import.test.ts b/tests/apply-scripts-inert-on-import.test.ts index 5308df6fc..d75bd3f96 100644 --- a/tests/apply-scripts-inert-on-import.test.ts +++ b/tests/apply-scripts-inert-on-import.test.ts @@ -82,7 +82,10 @@ const ALLOWED_CALLEES: Record = { }; const ALLOWED_GLOBAL_CALLEES = new Set(["process.argv.includes", "process.argv.indexOf"]); /** The only modules an apply script may import. Anything else (a `data:` URL, `dotenv/config`, a driver) runs code on import. */ -const ALLOWED_IMPORTS = new Set(["@prisma/client", "dotenv", "node:fs", "fs", "node:url", "url", "node:path", "path", "node:crypto", "crypto"]); +// `node:dns` is on the list for the same reason as `node:fs`: importing it does +// nothing at all - it opens no socket, reads no environment and starts no work. +// apply-receipt-intake.mjs resolves --expect-host at call time, inside main(). +const ALLOWED_IMPORTS = new Set(["@prisma/client", "dotenv", "node:fs", "fs", "node:url", "url", "node:path", "path", "node:crypto", "crypto", "node:dns", "dns"]); /** Names a script may never declare itself (they would let a guard or helper be spoofed). */ const RESERVED_NAMES = new Set(["process", "import", "pathToFileURL", "fileURLToPath", "dirname", "join", "resolve", "isMainModule"]); diff --git a/tests/automation-events-grouping.test.ts b/tests/automation-events-grouping.test.ts index 7558f77d7..406618cf3 100644 --- a/tests/automation-events-grouping.test.ts +++ b/tests/automation-events-grouping.test.ts @@ -229,3 +229,55 @@ test("journeyKey: falls back to docNumber+firstSeen only when neither driveFileI const j = { driveFileId: null, qbPurchaseId: null, docNumber: "DOC-3", firstSeen }; assert.equal(journeyKey(j), `DOC-3:${firstSeen.toISOString()}`); }); + +// ── A v2 receipt with no Drive file behind it still groups (round-14 C) ──── + +test("intakeId is a first-class identity, and never masquerades as a Drive id", async () => { + const { resolveEventFileId, resolveEventIntakeId } = + await import("../src/lib/automation-events"); + + const INTAKE_ID = "cmpd6xca1009x1iizdf4suln3"; + const doc = "cmpd6xca1009x1iizd"; + // A v2 receipt with no Drive file behind it: an intake beacon and the push + // event that booked it. Before the fix the worker put the intake cuid in + // `fileId`, which is dual-written into the `driveFileId` COLUMN — filling + // it with ids no Drive query can ever match. + const intake = fakeEvent({ + id: "e1", stage: "intake", status: "staged", docNumber: doc, + detail: JSON.stringify({ intakeId: INTAKE_ID }), + createdAt: new Date("2026-09-01T10:00:00Z"), + }); + const push = fakeEvent({ + id: "e2", status: "created", docNumber: doc, qbPurchaseId: "QB-1", + detail: JSON.stringify({ intakeId: INTAKE_ID, qbPurchaseId: "QB-1" }), + createdAt: new Date("2026-09-01T10:05:00Z"), + }); + + // Neither event claims a Drive id, because neither has one. + assert.equal(resolveEventFileId(intake), null); + assert.equal(resolveEventFileId(push), null); + assert.equal(resolveEventIntakeId(intake), INTAKE_ID); + + // They are still ONE receipt, joined on the intake id — proof, not the + // docNumber-prefix heuristic, which is explicitly a guess. + const journeys = [...groupEventsIntoJourneys([intake, push]).values()]; + assert.equal(journeys.length, 1); + assert.equal(journeys[0].steps.length, 2); + assert.equal(journeys[0].keyConfirmed, true, "an id match is proof, not a guess"); +}); + +test("two DIFFERENT v2 receipts sharing a docNumber prefix stay apart", () => { + // The control: the intake id is what keeps them separate. Without it both + // would fall into the prefix bucket and be presented as one receipt. + const doc = "COLLIDING-PREFIX-00000"; + const a = fakeEvent({ + id: "a", docNumber: doc, detail: JSON.stringify({ intakeId: "intake-a" }), + createdAt: new Date("2026-09-01T10:00:00Z"), + }); + const b = fakeEvent({ + id: "b", docNumber: doc, detail: JSON.stringify({ intakeId: "intake-b" }), + createdAt: new Date("2026-09-01T10:01:00Z"), + }); + const journeys = [...groupEventsIntoJourneys([a, b]).values()]; + assert.equal(journeys.length, 2, "two ids, two receipts"); +}); diff --git a/tests/cron-lease.test.ts b/tests/cron-lease.test.ts new file mode 100644 index 000000000..a5cc1faea --- /dev/null +++ b/tests/cron-lease.test.ts @@ -0,0 +1,185 @@ +/** + * The whole-invocation cron lease. + * + * The property under test is the one the receipt-intake worker's "one worker at + * a time" claim actually rests on, so it is asserted against a fake store that + * can be driven into the exact interleavings a production race would produce — + * rather than being asserted by reading the code, which is how the advisory + * lock came to be described as doing a job it never did. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { acquireCronLease, type CronLeaseStore } from "../src/lib/cron-lease"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const KEY = "test-lease"; + +/** An in-memory store with the same CAS semantics as the AutomationSetting one. */ +function memoryStore(seed: string | null = null) { + let value = seed; + const store: CronLeaseStore & { peek: () => string | null; failAll: boolean } = { + failAll: false, + peek: () => value, + async get() { + if (store.failAll) throw new Error("db down"); + return value; + }, + async insert(_key, next) { + if (store.failAll) throw new Error("db down"); + // The primary key is what makes this atomic in Postgres. + if (value !== null) return false; + value = next; + return true; + }, + async swap(_key, from, to) { + if (store.failAll) throw new Error("db down"); + if (value !== from) return false; + value = to; + return true; + }, + async remove(_key, expected) { + if (store.failAll) throw new Error("db down"); + if (value === expected) value = null; + }, + }; + return store; +} + +const at = (iso: string) => () => new Date(iso); + +test("the first invocation takes a lease that did not exist", async () => { + const store = memoryStore(); + const lease = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }); + assert.ok(lease); + assert.equal(lease.token, "A"); + assert.equal(store.peek(), "2026-09-01T12:01:30.000Z|A"); +}); + +test("TWO SIMULTANEOUS INVOCATIONS: exactly one gets to process", async () => { + // Both read the same absent row and both try to insert. In Postgres the + // primary key decides; here the fake store decides the same way. Whichever + // wins, the other must be told to do nothing — never both, and never + // neither. + const store = memoryStore(); + const now = at("2026-09-01T12:00:00.000Z"); + const [a, b] = await Promise.all([ + acquireCronLease(KEY, 90_000, { store, now, token: "A" }), + acquireCronLease(KEY, 90_000, { store, now, token: "B" }), + ]); + const winners = [a, b].filter(Boolean); + assert.equal(winners.length, 1, "exactly one invocation holds the lease"); +}); + +test("a second invocation arriving mid-pass is turned away", async () => { + const store = memoryStore(); + const first = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }); + assert.ok(first); + // 30 seconds into a pass whose lease runs for 90. + const second = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:30.000Z"), token: "B" }); + assert.equal(second, null); +}); + +test("a CRASHED invocation's stale lease expires and the next run proceeds", async () => { + // The invocation that took this lease was killed at the platform ceiling + // and never reached its `finally`. Nothing will ever release it, so the + // expiry has to be what frees the queue. + const store = memoryStore("2026-09-01T12:01:30.000Z|A"); + const next = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:05:00.000Z"), token: "B" }); + assert.ok(next, "the next cron five minutes later takes it over"); + assert.equal(store.peek(), "2026-09-01T12:06:30.000Z|B"); +}); + +test("two runs racing to take over the SAME expired lease: only one wins", async () => { + const store = memoryStore("2026-09-01T12:01:30.000Z|A"); + const now = at("2026-09-01T12:05:00.000Z"); + const [b, c] = await Promise.all([ + acquireCronLease(KEY, 90_000, { store, now, token: "B" }), + acquireCronLease(KEY, 90_000, { store, now, token: "C" }), + ]); + assert.equal([b, c].filter(Boolean).length, 1, "the CAS on the expired value settles it"); +}); + +test("releasing frees the lease immediately", async () => { + const store = memoryStore(); + const lease = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }); + await lease!.release(); + assert.equal(store.peek(), null); + // And the very next invocation may run — it does not wait out the TTL. + const next = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:01.000Z"), token: "B" }); + assert.ok(next); +}); + +test("an overrun invocation cannot release the lease that replaced it", async () => { + // A holds a lease, overruns, and its lease expires. B takes over and is + // mid-pass. A finally reaches its `finally` and releases — which must free + // NOTHING, or B would be running unprotected with a lease anyone can take. + const store = memoryStore(); + const a = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }); + const b = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:05:00.000Z"), token: "B" }); + assert.ok(b, "B took over the expired lease"); + + await a!.release(); + + assert.equal(store.peek(), "2026-09-01T12:06:30.000Z|B", "B still holds it"); +}); + +test("release never throws, even when the store is broken", async () => { + const store = memoryStore(); + const lease = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }); + store.failAll = true; + await lease!.release(); + // A lease left behind expires on its own. What must not happen is the run + // it protected failing because the cleanup did. +}); + +test("a store that cannot be read means NO lease — fail closed", async () => { + const store = memoryStore(); + store.failAll = true; + assert.equal( + await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }), + null, + "running unprotected is the thing the lease exists to prevent", + ); +}); + +test("a CORRUPT lease value reads as expired rather than wedging the cron forever", async () => { + // Nothing else writes this key, but a value that could never be parsed and + // was treated as "live" would stop the worker permanently with no way back + // short of a manual delete. The CAS still makes the takeover safe. + const store = memoryStore("not-a-date|???"); + const lease = await acquireCronLease(KEY, 90_000, { store, now: at("2026-09-01T12:00:00.000Z"), token: "A" }); + assert.ok(lease); +}); + +// ── Wiring ────────────────────────────────────────────────────────────────── +// +// Everything above proves the lease MECHANISM. None of it would notice the +// cron simply not taking one, or taking one that expires under its own pass — +// and neither would tsc, because both are optional-looking call-site details. +// These read the route, in the spirit of the sweep-query check in +// receipt-intake-worker.test.ts. + +const workerRoute = readFileSync( + path.join(__dirname, "..", "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", +); + +test("the receipt-intake worker actually takes a lease", () => { + assert.match( + workerRoute, + /acquireLease:\s*\(\)\s*=>\s*acquireCronLease\(WORKER_LEASE_KEY,\s*WORKER_LEASE_MS\)/, + "buildDeps must wire the lease, or runIntakeWorker's whole-pass exclusion is inert", + ); +}); + +test("the lease OUTLIVES the platform ceiling — which is why nothing heartbeats", () => { + // A lease shorter than maxDuration could lapse while its own pass was still + // running, letting a second invocation in on exactly the run it exists to + // exclude. The only alternative is heartbeating from a loop that spends its + // time blocked on Gemini and QuickBooks, so this inequality is load-bearing. + const ttl = Number(workerRoute.match(/const WORKER_LEASE_MS = ([\d_]+);/)?.[1].replace(/_/g, "")); + const maxDuration = Number(workerRoute.match(/export const maxDuration = (\d+);/)?.[1]) * 1_000; + assert.ok(Number.isFinite(ttl) && Number.isFinite(maxDuration), "both constants must be readable"); + assert.ok(ttl > maxDuration, `lease TTL ${ttl}ms must exceed the ${maxDuration}ms function ceiling`); +}); diff --git a/tests/payroll-writer-manifest.test.ts b/tests/payroll-writer-manifest.test.ts index 5b1e3ce28..bcf0b9961 100644 --- a/tests/payroll-writer-manifest.test.ts +++ b/tests/payroll-writer-manifest.test.ts @@ -153,15 +153,15 @@ const MANIFEST: Record = { kind: "guarded", why: "tagTimeEntriesToChangeOrder, wrapped in withPayrollWrite — retagging changes which change order the hours bill against. The change order and the rows are re-read INSIDE the lock and projectId is pinned in the WHERE, so an entry rerouted to another job between the pre-check and the write cannot pick up this project's change order", }, - "lib/time-expense-actions.ts:180::updateMany": { + "lib/time-expense-actions.ts:181::updateMany": { kind: "guarded", why: "the manual edit, wrapped in withPayrollWriteTx with the row re-read under FOR UPDATE", }, - "lib/time-expense-actions.ts:225::deleteMany": { + "lib/time-expense-actions.ts:226::deleteMany": { kind: "guarded", why: "deleteTimeEntry (single delete), wrapped in withPayrollWriteTx over the one affected row id", }, - "lib/time-expense-actions.ts:278::deleteMany": { + "lib/time-expense-actions.ts:279::deleteMany": { kind: "guarded", why: "deleteTimeEntries (bulk delete), wrapped in withPayrollWriteTx over every affected row id", }, diff --git a/tests/pipeline-digest-route.test.ts b/tests/pipeline-digest-route.test.ts index 9ac59bbcc..2247315ca 100644 --- a/tests/pipeline-digest-route.test.ts +++ b/tests/pipeline-digest-route.test.ts @@ -30,6 +30,12 @@ const HEALTH: PipelineHealth = { receipts24h: { status: "ok", counts: { created: 2 } }, bank: { status: "ok", at: "2026-08-29T00:00:00.000Z" }, stuck: { status: "ok", count: 0 }, + intake: { + stuck: { status: "ok", count: 0 }, + needsReview: { status: "ok", count: 0 }, + unassigned: { status: "ok", count: 0 }, + quarantined: { status: "ok", count: 0 }, + }, payLinksPending: { status: "ok", count: 0 }, }; diff --git a/tests/pipeline-health.test.ts b/tests/pipeline-health.test.ts index 73fafb634..73fedb2a7 100644 --- a/tests/pipeline-health.test.ts +++ b/tests/pipeline-health.test.ts @@ -11,6 +11,8 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; import { evaluatePipelineHealth, formatPipelineDigest, @@ -20,6 +22,8 @@ import { PROBE_CONCURRENCY, BOOKED_PUSH_STATUSES, type PipelineHealth, + INTAKE_STUCK_HOURS, + INTAKE_STAGING_STUCK_MINUTES, type ProbeRunner, } from "../src/lib/pipeline-health"; @@ -49,6 +53,10 @@ function snapshot(overrides: Partial[0 receipts24h: { status: "ok" as const, counts: { created: 4 } }, bank: { status: "ok" as const, at: iso(48 * HOUR) }, stuck: { status: "ok" as const, count: 0 }, + intakeStuck: { status: "ok" as const, count: 0 }, + intakeNeedsReview: { status: "ok" as const, count: 0 }, + intakeUnassigned: { status: "ok" as const, count: 0 }, + intakeQuarantined: { status: "ok" as const, count: 0 }, payLinksPending: { status: "ok" as const, count: 0 }, now: NOW, ...overrides, @@ -218,6 +226,12 @@ function sampleHealth(overrides: Partial = {}): PipelineHealth { receipts24h: { status: "ok", counts: { created: 4, fallback: 1 } }, bank: { status: "ok", at: "2026-08-29T00:00:00.000Z" }, stuck: { status: "ok", count: 0 }, + intake: { + stuck: { status: "ok", count: 0 }, + needsReview: { status: "ok", count: 0 }, + unassigned: { status: "ok", count: 0 }, + quarantined: { status: "ok", count: 0 }, + }, payLinksPending: { status: "ok" as const, count: 0 }, ...overrides, }; @@ -593,6 +607,175 @@ test("the journey mapper renders attachment-failed as failed, not in-flight", as assert.equal(journey.finalReason, "failed:fault"); }); +// ── Receipt Pipeline v2 intake queue ─────────────────────────────────────── +// Every other probe in this file reads AutomationEvent, which only records a +// BOOKING — so a v2 row that never reaches QuickBooks is invisible to all of +// them. A jammed intake queue reported a perfectly healthy pipeline. + +test("rows stuck in the intake queue fail the check and name the backlog", () => { + const v = evaluatePipelineHealth(snapshot({ + intakeStuck: { status: "ok", count: 4 }, + intakeNeedsReview: { status: "ok", count: 11 }, + })); + assert.equal(v.ok, false); + assert.deepEqual(v.reasons, ["intake-stuck:4,needs-review:11"]); +}); + +test("a NEEDS_REVIEW backlog alone is NOT a failure", () => { + // Those rows are working as designed — a human was asked a question. + // Failing on them would hold the pipeline red until somebody cleared the + // queue, which trains everyone to ignore the signal. + const v = evaluatePipelineHealth(snapshot({ intakeNeedsReview: { status: "ok", count: 40 } })); + assert.deepEqual(v, { ok: true, reasons: [] }); +}); + +test("receipts nobody assigned a job to are an ALERT, not a green backlog", () => { + // NEEDS_JOB is terminal for the worker, so it can pile up indefinitely + // while every other probe reads green. Its own reason, because the fix is + // different: assign a project, not restart a worker. + const v = evaluatePipelineHealth(snapshot({ intakeUnassigned: { status: "ok", count: 5 } })); + assert.equal(v.ok, false); + assert.deepEqual(v.reasons, ["intake-unassigned:5"]); +}); + +test("stuck and unassigned are reported separately", () => { + const v = evaluatePipelineHealth(snapshot({ + intakeStuck: { status: "ok", count: 2 }, + intakeUnassigned: { status: "ok", count: 3 }, + })); + assert.ok(v.reasons.some(r => r.startsWith("intake-stuck:2"))); + assert.ok(v.reasons.includes("intake-unassigned:3")); +}); + +test("QUARANTINED cutover rows are counted, and get their own reason", () => { + // SHADOW_QUARANTINE is terminal, never auto-requeued, and NOBODY has + // booked it: v1 stopped, and v2 refused because there is no shared QBO + // identity to make a second booking idempotent. It is neither NEEDS_REVIEW + // nor NEEDS_JOB, so before this it was invisible to every probe here and a + // pile of unbooked expenses read as a healthy pipeline. + const v = evaluatePipelineHealth(snapshot({ intakeQuarantined: { status: "ok", count: 3 } })); + assert.equal(v.ok, false); + assert.deepEqual(v.reasons, ["receipt-quarantine:3"]); +}); + +test("the quarantine reason is separate from review and unassigned", () => { + // Three different actions: check QuickBooks and decide, clear a review + // item, assign a job. Folding them into one number hides two of them. + const v = evaluatePipelineHealth(snapshot({ + intakeUnassigned: { status: "ok", count: 2 }, + intakeQuarantined: { status: "ok", count: 4 }, + })); + assert.ok(v.reasons.includes("intake-unassigned:2")); + assert.ok(v.reasons.includes("receipt-quarantine:4")); +}); + +test("CONTROL: a NEEDS_REVIEW backlog does not produce a quarantine reason", () => { + // Without this, a count that accidentally selected every parked state + // would still pass the test above. + const v = evaluatePipelineHealth(snapshot({ intakeNeedsReview: { status: "ok", count: 40 } })); + assert.deepEqual(v.reasons, []); +}); + +test("an intake probe that FAILED is not an intake probe that found nothing", () => { + for (const name of ["intakeStuck", "intakeNeedsReview", "intakeUnassigned", "intakeQuarantined"] as const) { + const v = evaluatePipelineHealth(snapshot({ [name]: { status: "error", reason: "timeout", count: 0 } })); + assert.equal(v.ok, false, name); + assert.ok(v.reasons.includes(`probe-failed:${name}`), name); + } +}); + +test("the stuck reason survives a failed backlog probe rather than lying about it", () => { + const v = evaluatePipelineHealth(snapshot({ + intakeStuck: { status: "ok", count: 2 }, + intakeNeedsReview: { status: "error", reason: "error", count: 0 }, + })); + assert.ok(v.reasons.includes("intake-stuck:2"), "no invented needs-review count"); + assert.ok(v.reasons.includes("probe-failed:intakeNeedsReview")); +}); + +test("STAGING gets a much shorter fuse than the working states", () => { + // STAGING is meant to last one HTTP request; RECEIVED/BOOKING/READ are + // queue states measured in hours. + assert.equal(INTAKE_STAGING_STUCK_MINUTES, 30); + assert.equal(INTAKE_STUCK_HOURS, 6); + assert.ok(INTAKE_STAGING_STUCK_MINUTES * 60_000 < INTAKE_STUCK_HOURS * 3_600_000); +}); + +// ── A STAGING row's own upload lease, not just its age (Codex round-17 item 5) ── + +test("a STAGING row is not counted as stuck while its own upload lease is still live", () => { + // The count() query talks to real Prisma, so this is a source-level pin + // (same technique receipt-url.test.ts and receipt-intake-stored-object. + // test.ts use for the properties a live DB is needed to exercise for + // real): the STAGING branch of intakeStuck must gate on the lease, not + // on createdAt alone, or a client mid-upload on a slow connection — + // whose /start re-issued a signed URL without touching createdAt — reads + // as "stuck" while its own link is still perfectly good. + const root = path.resolve(__dirname, ".."); + const src = readFileSync(path.join(root, "src/lib/pipeline-health.ts"), "utf8"); + const stagingBranch = src.slice( + src.indexOf('state: "STAGING"'), + src.indexOf('state: "READ"'), + ); + assert.match( + stagingBranch, + /uploadUrlExpiresAt/, + "the STAGING stuck-count must consult the upload lease, not createdAt alone", + ); +}); + +test("the quarantine PROBE actually counts SHADOW_QUARANTINE, with no age gate", () => { + // Same source-level pin as the STAGING test above, for the same reason: + // count() talks to real Prisma. Two properties, and both matter — the + // state it selects, and that it does NOT carry a createdAt threshold. A + // quarantined row is terminal the instant the cutover writes it, so an + // age gate copied from the NEEDS_JOB probe next door would hide every one + // of them for six hours for no reason. + const root = path.resolve(__dirname, ".."); + const src = readFileSync(path.join(root, "src/lib/pipeline-health.ts"), "utf8"); + // Anchored on the probe DECLARATION, not on the name — the name also + // appears in namedProbes above, and slicing from there would read the + // whole evaluator and pass on any mention of the state anywhere. + const declared = /probe\(\r?\n\s*"intakeQuarantined",([\s\S]*?)\r?\n\s*\),/.exec(src); + assert.ok(declared, "the probe exists"); + const branch = declared[1]; + assert.match(branch, /state: "SHADOW_QUARANTINE"/); + assert.ok(!branch.includes("createdAt"), "no age threshold on a terminal state"); + // And it is a REASON, not just a printed number. + assert.match(src, /receipt-quarantine:\$\{input\.intakeQuarantined\.count\}/); +}); + +test("the digest prints all four intake numbers", () => { + const { text } = formatPipelineDigest(sampleHealth({ + intake: { + stuck: { status: "ok", count: 3 }, + needsReview: { status: "ok", count: 7 }, + unassigned: { status: "ok", count: 2 }, + quarantined: { status: "ok", count: 5 }, + }, + })); + assert.match(text, /Receipt intake stuck >6h: 3/); + assert.match(text, /Receipt intake awaiting review: 7/); + assert.match(text, /Receipt intake awaiting a job \(>6h\): 2/); + // SHADOW_QUARANTINE rows are terminal and unbooked, and no other line here + // can see them: before this they were invisible to the whole digest. + assert.match(text, /Receipt intake quarantined \(cutover, needs a decision\): 5/); +}); + +test("the digest says a failed intake probe is unavailable, never zero", () => { + const { text } = formatPipelineDigest(sampleHealth({ + intake: { + stuck: { status: "error", reason: "timeout", count: 0 }, + needsReview: { status: "error", reason: "timeout", count: 0 }, + unassigned: { status: "error", reason: "timeout", count: 0 }, + quarantined: { status: "error", reason: "timeout", count: 0 }, + }, + })); + assert.match(text, /Receipt intake stuck >6h: unavailable \(probe failed\)/); + assert.match(text, /Receipt intake awaiting review: unavailable \(probe failed\)/); + assert.match(text, /Receipt intake quarantined \(cutover, needs a decision\): unavailable \(probe failed\)/); +}); + // ─── A refused credential names its own fix ───────────────────────────────── test("a QuickBooks auth refusal reads as reconnect-needed, not a generic error", () => { diff --git a/tests/qb-timed-fetch.test.ts b/tests/qb-timed-fetch.test.ts index b2830b6ac..aed7b876f 100644 --- a/tests/qb-timed-fetch.test.ts +++ b/tests/qb-timed-fetch.test.ts @@ -503,6 +503,109 @@ test("CUMULATIVE latency: serial calls stop before the route ceiling", async () assert.ok(calls > 1, "should have made several calls before stopping"); }); +// --- The route budget reaches the LATE calls of ensureQBCustomer --- + +/** + * ensureQBCustomer accepted a RouteDeadline but passed it to none of its four + * QBO calls, so the stored-id check, the exact-name lookup, the LIKE-prefix + * scan and the create each opened a fresh 20s window. Four of those in series + * outlive any route ceiling, which is how a receipt push got killed mid-write. + * + * These use a stubbed global fetch rather than the local server above: the URL + * is built from QB_API_BASE inside the module, so it cannot be pointed here. + * (That is not the `mock.module` hazard — no module identity is replaced.) + * + * Each test spends the budget INSIDE the preceding call, then asserts the next + * one is never issued. The no-deadline control in the same test is what makes + * that meaningful: without it the assertion would also pass on code that made + * no call at all. + */ +const CUSTOMER_TOKENS = { accessToken: "a", refreshToken: "r", realmId: "realm-1" }; + +/** 1.4s of budget, spent by a 600ms call: the next call starts under the 1s floor. */ +const CUSTOMER_BUDGET_MS = 1_400; + +async function withFetch(impl: typeof fetch, run: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = impl; + try { + return await run(); + } finally { + globalThis.fetch = original; + } +} + +function customerFetchStub(options: { burnAtCall: number }) { + const urls: string[] = []; + const impl = (async (url: string | URL, init?: RequestInit) => { + const u = String(url); + urls.push(u); + if (urls.length === options.burnAtCall) { + await new Promise(resolve => setTimeout(resolve, 600)); + } + if (u.includes("/query?query=")) { + // Every lookup misses, so the sequence always runs to the create. + return new Response(JSON.stringify({ QueryResponse: {} }), { status: 200 }); + } + if (u.includes("/customer?") && init?.method === "POST") { + return new Response(JSON.stringify({ Customer: { Id: "cust-new" } }), { status: 200 }); + } + throw new Error(`Unexpected fetch in test: ${u}`); + }) as unknown as typeof fetch; + return { impl, urls }; +} + +test("ensureQBCustomer: the stored-id trust check is refused once the budget is gone", async () => { + const { createRouteDeadline, isQBBudgetExhaustedError, ensureQBCustomer } = await import("../src/lib/quickbooks"); + const client = { name: "Mueller Remodel", qbCustomerId: "cust-stored" }; + + const spent = customerFetchStub({ burnAtCall: 99 }); + // Budget started 10s ago with only 2s allowed: nothing left at entry. + const error = await withFetch(spent.impl, () => + ensureQBCustomer(CUSTOMER_TOKENS, client, createRouteDeadline(2_000, Date.now() - 10_000)), + ).then(() => null, (e: unknown) => e as Error); + assert.ok(isQBBudgetExhaustedError(error), `got ${String(error)}`); + assert.equal(spent.urls.length, 0, "not one QBO call may start with no budget left"); + + const control = customerFetchStub({ burnAtCall: 99 }); + assert.equal(await withFetch(control.impl, () => ensureQBCustomer(CUSTOMER_TOKENS, client)), "cust-new"); + assert.match(decodeURIComponent(control.urls[0]), /WHERE Id = 'cust-stored'/); + assert.equal(control.urls.length, 4); +}); + +test("ensureQBCustomer: the candidate scan is refused once the budget is gone", async () => { + const { createRouteDeadline, isQBBudgetExhaustedError, ensureQBCustomer } = await import("../src/lib/quickbooks"); + const client = { name: "Mueller Remodel" }; + + const spent = customerFetchStub({ burnAtCall: 1 }); + const error = await withFetch(spent.impl, () => + ensureQBCustomer(CUSTOMER_TOKENS, client, createRouteDeadline(CUSTOMER_BUDGET_MS)), + ).then(() => null, (e: unknown) => e as Error); + assert.ok(isQBBudgetExhaustedError(error), `got ${String(error)}`); + assert.equal(spent.urls.length, 1, "the LIKE-prefix scan must not be issued"); + + const control = customerFetchStub({ burnAtCall: 1 }); + assert.equal(await withFetch(control.impl, () => ensureQBCustomer(CUSTOMER_TOKENS, client)), "cust-new"); + assert.equal(control.urls.length, 3); + assert.match(decodeURIComponent(control.urls[1]), /DisplayName LIKE 'Mueller%'/); +}); + +test("ensureQBCustomer: the create is refused once the budget is gone", async () => { + const { createRouteDeadline, isQBBudgetExhaustedError, ensureQBCustomer } = await import("../src/lib/quickbooks"); + const client = { name: "Mueller Remodel" }; + + const spent = customerFetchStub({ burnAtCall: 2 }); + const error = await withFetch(spent.impl, () => + ensureQBCustomer(CUSTOMER_TOKENS, client, createRouteDeadline(CUSTOMER_BUDGET_MS)), + ).then(() => null, (e: unknown) => e as Error); + assert.ok(isQBBudgetExhaustedError(error), `got ${String(error)}`); + assert.equal(spent.urls.length, 2, "no Customer may be created with no budget left"); + + const control = customerFetchStub({ burnAtCall: 2 }); + assert.equal(await withFetch(control.impl, () => ensureQBCustomer(CUSTOMER_TOKENS, client)), "cust-new"); + assert.equal(control.urls.length, 3); + assert.match(control.urls[2], /\/customer\?/); +}); // --- Reading the ERROR body is a body read too --- diff --git a/tests/qbo-payments-cursor.test.ts b/tests/qbo-payments-cursor.test.ts new file mode 100644 index 000000000..ef8ed91ad --- /dev/null +++ b/tests/qbo-payments-cursor.test.ts @@ -0,0 +1,317 @@ +/** + * The payment sweep's RESUME CURSOR. + * + * The bug: `forEachPendingPage` reset its cursor to null on every invocation. + * Ordering by id had already made each run deterministic — which is exactly + * what turned a soft problem into a permanent one. With more than + * PAYMENTS_SYNC_MAX_ROWS unpaid rows, every hourly cron re-probed the SAME + * lowest 500 ids, and rows past that cap were never verified. Not "eventually"; + * never, for as long as the leading rows stayed unpaid. + * + * These tests drive the REAL pagination function against an in-memory + * collection, so what is measured is the shipped traversal rather than a + * restatement of it. Each one carries the control that makes it mean + * something: the same fixture with no cursor, which must starve. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + forEachPendingPage, + countUnvisited, + PAYMENTS_CURSOR_KEYS, + type PaymentsSyncCursorStore, + type QBPaymentSyncResult, +} from "../src/lib/quickbooks-payments"; +import { createRouteDeadline } from "../src/lib/quickbooks"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const TOTAL = 600; // deliberately > the 500-row cap +const KEY = PAYMENTS_CURSOR_KEYS.milestones; + +function emptyResult(): QBPaymentSyncResult { + return { + checked: 0, settled: 0, partiallyPaid: 0, errors: [], progressBillingsSettled: 0, + skipped: 0, abortedOnQboOutage: false, runFailed: false, + }; +} + +/** Zero-padded so lexical id order is the same order a human would expect. */ +const idAt = (n: number) => `id-${String(n).padStart(3, "0")}`; + +function memoryCursorStore(): PaymentsSyncCursorStore & { peek(): string | null; gets: number; sets: string[] } { + let value: string | null = null; + return { + gets: 0, + sets: [], + peek: () => value, + async get() { this.gets++; return value; }, + async set(_key, next) { this.sets.push(next); value = next; }, + }; +} + +/** + * One run over a collection that never changes — the "unchanged pending rows" + * case, which is the one that starved. Returns the ids this run visited. + */ +async function runOnce( + store: PaymentsSyncCursorStore | undefined, + total = TOTAL, + opts: { stopAfter?: number; quietStopAfter?: number } = {}, +): Promise<{ visited: string[]; result: QBPaymentSyncResult }> { + const all = Array.from({ length: total }, (_, i) => ({ id: idAt(i) })); + const visited: string[] = []; + const result = emptyResult(); + + await forEachPendingPage( + result, + createRouteDeadline(100_000), + async (cursorId, take, stopAfterId) => + all + .filter(r => (cursorId ? r.id > cursorId : true)) + .filter(r => (stopAfterId ? r.id <= stopAfterId : true)) + .slice(0, take), + (state) => countUnvisited( + (where) => { + const w = (where.id ?? {}) as { gt?: string; lte?: string }; + return Promise.resolve(all.filter(r => + (w.gt ? r.id > w.gt : true) && (w.lte ? r.id <= w.lte : true), + ).length); + }, + state, + ), + async (page) => { + let lastCompletedId: string | null = null; + for (const row of page) { + // A simulated mid-run stop: an outage or the budget wall lands + // partway through a page, and the cursor must not step over the + // rows it never reached. + if (opts.stopAfter !== undefined && visited.length >= opts.stopAfter) { + result.abortedOnQboOutage = true; + break; + } + // A QUIET stop: the handler ran out of per-row budget and + // simply returned early, setting no flag. This is the case the + // short-page branch got wrong — it `return`s before the loop's + // own guards ever get to look at anything. + if (opts.quietStopAfter !== undefined && visited.length >= opts.quietStopAfter) break; + visited.push(row.id); + result.checked++; + lastCompletedId = row.id; + } + return { lastCompletedId }; + }, + store ? { store, key: KEY } : undefined, + ); + + return { visited, result }; +} + +test("CONTROL: with no persisted cursor every run re-probes the SAME lowest 500 ids", async () => { + // This is the reported behaviour, reproduced. Without it the test below + // would pass against a collection that simply fitted under the cap. + const runs = [await runOnce(undefined), await runOnce(undefined), await runOnce(undefined)]; + for (const run of runs) { + assert.equal(run.visited.length, 500, "each run stops at the cap"); + assert.equal(run.visited[0], idAt(0)); + assert.equal(run.visited[499], idAt(499)); + } + const everSeen = new Set(runs.flatMap(r => r.visited)); + assert.equal(everSeen.has(idAt(500)), false, "row 500 is never reached, run after run"); + assert.equal(everSeen.has(idAt(599)), false, "nor is the last row"); +}); + +test("the cursor persists between invocations, so later ids ARE probed", async () => { + const store = memoryCursorStore(); + + const first = await runOnce(store); + assert.equal(first.visited.length, 500); + assert.equal(first.visited[0], idAt(0)); + assert.equal(store.peek(), idAt(499), "the run records where it stopped"); + + const second = await runOnce(store); + assert.equal( + second.visited[0], + idAt(500), + "the next invocation RESUMES after the last processed id rather than restarting", + ); + assert.ok( + second.visited.includes(idAt(599)), + "the rows past the cap — never reachable before — are verified on the very next run", + ); +}); + +test("it wraps to the top only AFTER the tail is drained, and covers everything", async () => { + const store = memoryCursorStore(); + const seen = new Set(); + // Two runs is enough to cover 600 rows at 500 a run; a third proves the + // cycle keeps rolling rather than stalling at the end of the collection. + for (let i = 0; i < 3; i++) { + const run = await runOnce(store); + for (const id of run.visited) seen.add(id); + } + assert.equal(seen.size, TOTAL, "every row in the collection has been verified"); +}); + +test("a wrapped pass never re-walks the rows it already did this run", async () => { + const store = memoryCursorStore(); + await runOnce(store); // leaves the cursor at id-499 + + const second = await runOnce(store); + const counts = new Map(); + for (const id of second.visited) counts.set(id, (counts.get(id) ?? 0) + 1); + const repeated = [...counts.entries()].filter(([, n]) => n > 1); + assert.deepEqual(repeated, [], "stopAfterId bounds the wrapped pass"); + // It walked the tail (500-599) first, then wrapped to the head and stopped + // at the old cursor — never past it. + assert.deepEqual( + second.visited.slice(0, 100), + Array.from({ length: 100 }, (_, i) => idAt(500 + i)), + "the tail, in order", + ); + assert.equal(second.visited[100], idAt(0), "then back to the top"); + assert.ok( + second.visited.slice(100).every(id => id <= idAt(499)), + "and the wrapped pass never crosses the point this run started from", + ); +}); + +test("a run cut short mid-page resumes at the first UNVERIFIED row, not past it", async () => { + // The cursor may only advance to the last row actually completed. Jumping + // to the page tail after an outage would step over every row the outage + // cut short, and they would wait a whole cycle to be looked at again. + const store = memoryCursorStore(); + const first = await runOnce(store, TOTAL, { stopAfter: 37 }); + assert.equal(first.visited.length, 37); + assert.equal(store.peek(), idAt(36), "the cursor sits on the last COMPLETED row"); + + const second = await runOnce(store); + assert.equal(second.visited[0], idAt(37), "the next run picks up exactly where it stopped"); +}); + +test("a fully drained collection resets to the top rather than resuming from the end", async () => { + const store = memoryCursorStore(); + const run = await runOnce(store, 40); // well under the cap + assert.equal(run.visited.length, 40); + assert.equal(store.peek(), "", "empty string is how 'start from the top' is stored"); + assert.equal(run.result.skipped, 0, "nothing was missed, so nothing is reported skipped"); +}); + +test("a capped run reports what it did NOT reach, including the head it resumed past", async () => { + // A run that resumed at C and stopped at D has left both (> D) and (<= C) + // unverified. Counting only "after the cursor" called such a run clean. + const store = memoryCursorStore(); + const first = await runOnce(store); + assert.equal(first.result.skipped, 100, "600 rows, 500 checked"); + + const second = await runOnce(store); + assert.equal( + second.result.checked + second.result.skipped, + TOTAL, + "every row is accounted for as either checked or skipped", + ); +}); + +test("a run with NO cursor never reads or writes the store", async () => { + // The scoped on-view refresh passes no cursor: it looks at the handful of + // rows a user is staring at, so reading the shared cursor would make it + // skip the very row it was asked about, and writing one would move the + // cron's resume point to wherever that user happened to be looking. + const store = memoryCursorStore(); + await runOnce(undefined); + assert.equal(store.gets, 0); + assert.deepEqual(store.sets, []); +}); + +// ── Wiring ────────────────────────────────────────────────────────────────── +// +// Every test above drives forEachPendingPage directly, so all of them still +// pass if the sync simply stops handing it a cursor — and tsc would not object +// either, since the argument is optional. Reading the source is the only check +// available without standing up Postgres, and it is the same technique the +// worker suite uses for its sweep query. + +const paymentsSource = readFileSync( + path.join(__dirname, "..", "src/lib/quickbooks-payments.ts"), + "utf8", +); + +test("both rails are given their resume cursor", () => { + assert.match(paymentsSource, /^\s*milestoneCursor,$/m, "the milestone pass must resume"); + assert.match(paymentsSource, /^\s*billingCursor,$/m, "so must the progress-billing pass"); +}); + +test("only the UNSCOPED sweep carries a cursor", () => { + // A scoped on-view refresh looks at a handful of rows a user is staring at. + // Reading the shared cursor would make it skip the very row it was asked + // about; writing one would drag the cron's resume point to wherever that + // user happened to be looking, starving everything after it. + assert.match(paymentsSource, /const isSweep = !scope\?\.invoiceId && !scope\?\.projectId;/); + assert.match(paymentsSource, /const milestoneCursor = isSweep\s*\r?\n\s*\?\s*\{ store: cursorStore, key: PAYMENTS_CURSOR_KEYS\.milestones \}\s*\r?\n\s*: undefined;/); + assert.match(paymentsSource, /const billingCursor = isSweep\s*\r?\n\s*\?\s*\{ store: cursorStore, key: PAYMENTS_CURSOR_KEYS\.billings \}\s*\r?\n\s*: undefined;/); +}); + +// ── A SHORT page is not a DRAINED page (Codex round-15 item 3) ───────────── +// +// `page.length < take` means "the collection has no more rows", and the branch +// that reads it resets the cursor to the top and RETURNS — skipping +// `countRemaining` entirely. That is only true if the handler actually +// finished the page. A 40-row final page stopped after row 10 by a deadline or +// an outage had rows 11-40 thrown away AND never counted as skipped: the run +// reported a clean drain and thirty payments went unverified until the window +// happened to roll back over them. + +test("a SHORT final page stopped mid-way keeps its cursor and counts the tail", async () => { + const store = memoryCursorStore(); + // 40 rows is fewer than one page, so the very first fetch is "short". + const { visited, result } = await runOnce(store, 40, { quietStopAfter: 10 }); + + assert.equal(visited.length, 10, "the handler stopped after ten rows"); + assert.equal( + store.peek(), + idAt(9), + "the cursor stayed at the last row that actually completed", + ); + assert.notEqual(store.peek(), "", "it was NOT reset to the top"); + assert.equal(result.skipped, 30, "and the unvisited tail is counted, not lost"); +}); + +test("the NEXT run resumes into the tail rather than re-walking the head", async () => { + // The consequence: the rows the short page never reached are the first + // thing the following run sees. + const store = memoryCursorStore(); + await runOnce(store, 40, { quietStopAfter: 10 }); + const second = await runOnce(store, 40); + assert.equal(second.visited[0], idAt(10), "it picks up exactly where the last one stopped"); + assert.deepEqual( + second.visited.slice(0, 30), + Array.from({ length: 30 }, (_, i) => idAt(10 + i)), + "the tail the short page abandoned is walked first, in order", + ); + // Then it WRAPS, because a run that resumed mid-collection has rows before + // its start point that a fixed start would never reach. 40 = the 30-row + // tail plus the 10-row head. + assert.equal(second.visited.length, 40); + assert.equal(second.visited[30], idAt(0), "and the head follows the wrap"); +}); + +test("CONTROL: a short page the handler FINISHED still drains and wraps", async () => { + // Without this, a fix that simply never took the drain branch would pass + // the tests above while stalling the cursor forever. + const store = memoryCursorStore(); + const { visited, result } = await runOnce(store, 40); + assert.equal(visited.length, 40, "the whole collection"); + assert.equal(store.peek(), "", "drained: the next run starts at the top"); + assert.equal(result.skipped, 0, "nothing left over"); +}); + +test("CONTROL: a FULL page stopped mid-way is unchanged", async () => { + // The full-page case never went through the short-page branch, so it must + // behave exactly as it did — the loop's own outage guard stops it and the + // counting path runs. + const store = memoryCursorStore(); + const { visited, result } = await runOnce(store, TOTAL, { stopAfter: 10 }); + assert.equal(visited.length, 10); + assert.equal(store.peek(), idAt(9)); + assert.equal(result.skipped, TOTAL - 10); +}); diff --git a/tests/qbo-receipt-push.test.ts b/tests/qbo-receipt-push.test.ts index 98b29ae2c..c2353e4ba 100644 --- a/tests/qbo-receipt-push.test.ts +++ b/tests/qbo-receipt-push.test.ts @@ -1,13 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; import { createQBReceiptPurchase, ensureQBVendor, QboAccountConfigError, QboVendorDuplicateError, QboPurchaseFaultError, + stableAttachmentFileName, + compareExistingPurchase, + readBookedPurchase, type CreateQBReceiptPurchaseInput, + type ExistingPurchaseCheck, type QboReceiptProjectCandidate, type QboReceiptPushDependencies, type ReceiptAttachmentStatus, @@ -54,6 +60,47 @@ function baseInput(overrides: Partial = {}): Creat }; } +/** + * A QBO Purchase that AGREES with baseInput(), in the shape QBO really returns + * one: TotalAmt and TxnDate on the entity, the vendor on EntityRef, and the job + * on each expense line's CustomerRef. + * + * The fixtures used to be `{ Id, PrivateNote }` because those were the only two + * fields the code selected — which is exactly the finding: two fields can say + * "this is our Purchase" and cannot say "and it agrees with this document". + */ +function bookedPurchase(over: Record = {}): Record { + return { + Id: "purchase-99", + TotalAmt: 150, + TxnDate: "2026-07-15", + EntityRef: { value: "vendor-1", name: "Home Depot", type: "Vendor" }, + Line: [{ + Amount: 150, + DetailType: "AccountBasedExpenseLineDetail", + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID, name: "COGS Supplies & materials" }, + CustomerRef: { value: "cust-1", name: "Mueller Remodel" }, + }, + }], + ...over, + }; +} + +/** The verdict a matching books row produces, for the result deepEquals below. */ +const MATCHED = { + verdict: "match", + differences: [], + booked: { + totalAmount: 150, + txnDate: "2026-07-15", + vendor: "Home Depot", + projectNames: ["Mueller Remodel"], + lines: [{ tax: false, customerId: "cust-1", customerName: "Mueller Remodel", readable: true }], + taxAmount: 0, + }, +}; + /** The account-identity check runs against whatever id was queried — return the shape it expects for either. */ function defaultAccountRow(query: string): Array<{ Id: string; Name: string; AccountType: string }> { if (query.includes(`'${BANK_ACCOUNT_ID}'`)) { @@ -69,7 +116,7 @@ function defaultAccountRow(query: string): Array<{ Id: string; Name: string; Acc } interface DepsOverrides { - existingRows?: Array<{ Id: string; PrivateNote?: string }>; + existingRows?: Array>; createdId?: string; customerId?: string; vendorId?: string; @@ -161,7 +208,7 @@ function createDeps(overrides: DepsOverrides = {}) { test("createQBReceiptPurchase short-circuits when the DocNumber and marker both match", async () => { const input = baseInput(); const marker = `[gtr-file:${input.fileId}]`; - const { deps, calls } = createDeps({ existingRows: [{ Id: "purchase-99", PrivateNote: `note ${marker}` }] }); + const { deps, calls } = createDeps({ existingRows: [bookedPurchase({ PrivateNote: `note ${marker}` })] }); const result = await createQBReceiptPurchase(TOKENS, input, deps); assert.deepEqual(result, { @@ -171,6 +218,7 @@ test("createQBReceiptPurchase short-circuits when the DocNumber and marker both alreadyExists: true, // No file in this input, so there is nothing to attach. attachment: "skipped", + existing: MATCHED, }); assert.equal(calls.creates.length, 0); assert.equal(calls.vendorCalls.length, 0); @@ -540,7 +588,7 @@ function createRouteHandlers(overrides: Partial TOKENS), createPurchase: overrides.createPurchase ?? - (async () => ({ ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const })), + (async () => ({ ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const, existing: MATCHED as ExistingPurchaseCheck })), // Stub the audit logger: unit tests must never touch the real Prisma client. logEvent: overrides.logEvent ?? (() => {}), // Same for the pause switch — the real read fails CLOSED (paused) with no DB. @@ -561,7 +609,7 @@ test("route POST forwards tax:true only as an explicit boolean — string \"true const { POST } = createRouteHandlers({ createPurchase: async (_tokens, input) => { inputs.push(input); - return { ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const }; + return { ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const, existing: MATCHED as ExistingPurchaseCheck }; }, }); const response = await POST(new Request("https://example.test/api/integrations/qbo-receipts/create", { @@ -876,7 +924,7 @@ test("route POST forwards overheadCategory only as a string", async () => { const { POST } = createRouteHandlers({ createPurchase: async (_tokens, input) => { inputs.push(input); - return { ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const }; + return { ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const, existing: MATCHED as ExistingPurchaseCheck }; }, }); for (const overheadCategory of ["Meals", 42]) { @@ -918,7 +966,7 @@ test("already-exists uploads the receipt when the lost first attempt never attac const marker = `[gtr-file:${input.fileId}]`; const uploads: Array<{ purchaseId: string; fileName: string }> = []; const { deps, calls } = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], attachableRows: [], // QBO has the Purchase but no file on it uploadAttachment: async (_t, purchaseId, file) => { uploads.push({ purchaseId, fileName: file.fileName }); @@ -934,10 +982,13 @@ test("already-exists uploads the receipt when the lost first attempt never attac docNumber: input.fileId.slice(0, 21), alreadyExists: true, attachment: "attached", + existing: MATCHED, }); // Still idempotent on the books: no second Purchase. assert.equal(calls.creates.length, 0); - assert.deepEqual(uploads, [{ purchaseId: "99", fileName: "receipt.jpg" }]); + assert.deepEqual(uploads, [ + { purchaseId: "99", fileName: stableAttachmentFileName(input.fileId, input.fileName) }, + ]); }); test("already-exists does NOT re-upload when the deterministic filename is already attached", async () => { @@ -945,8 +996,8 @@ test("already-exists does NOT re-upload when the deterministic filename is alrea const marker = `[gtr-file:${input.fileId}]`; let uploadCount = 0; const { deps } = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], - attachableRows: [attachableRow("99", "receipt.jpg")], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], + attachableRows: [attachableRow("99", stableAttachmentFileName(input.fileId, input.fileName))], uploadAttachment: async () => { uploadCount += 1; return "attached"; @@ -959,11 +1010,36 @@ test("already-exists does NOT re-upload when the deterministic filename is alrea assert.equal(uploadCount, 0, "an existing attachment must never be duplicated"); }); +test("already-exists does NOT treat an unrelated receipt's identical caller-chosen filename as a match", async () => { + // Two different receipts, both uploaded by a phone that names every photo + // "receipt.jpg" — the exact collision stableAttachmentFileName exists to + // prevent. The Attachable on file belongs to a DIFFERENT fileId, so it + // must never be read as "this receipt is already attached". + const input = baseInput({ ...FILE_INPUT, fileId: "totally-different-receipt-id" }); + const marker = `[gtr-file:${input.fileId}]`; + let uploadCount = 0; + const { deps } = createDeps({ + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], + // Same caller-chosen "FileName" as the unrelated receipt would have + // produced under the old naive scheme, but it is not OUR stable name. + attachableRows: [attachableRow("99", "receipt.jpg")], + uploadAttachment: async () => { + uploadCount += 1; + return "attached"; + }, + }); + + const result = await createQBReceiptPurchase(TOKENS, input, deps); + + assert.equal(result.ok && result.alreadyExists && result.attachment, "attached"); + assert.equal(uploadCount, 1, "an unrelated same-name attachment must not short-circuit the real upload"); +}); + test("already-exists ignores an Attachable that belongs to a different entity type", async () => { const input = baseInput({ ...FILE_INPUT }); const marker = `[gtr-file:${input.fileId}]`; const { deps } = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], // Same id + filename, but linked to an Invoice — entity ids are only // unique per type, so this must not count as our receipt. attachableRows: [{ @@ -985,7 +1061,7 @@ test("a failed attachment LOOKUP is retryable, not a terminal ok:true", async () const input = baseInput({ ...FILE_INPUT }); const marker = `[gtr-file:${input.fileId}]`; const { deps } = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], attachableQueryImpl: async () => { throw new Error("QBO down"); }, @@ -1016,7 +1092,7 @@ test("an attachment upload that times out PROPAGATES from both paths, so the pus ); const existing = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], attachableRows: [], uploadAttachment: async () => timeout(), }); @@ -1040,7 +1116,7 @@ test("a thrown NETWORK-ish attachment failure is retryable from both paths", asy ); const existing = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], attachableRows: [], uploadAttachment: boom, }); @@ -1060,7 +1136,7 @@ test("a TERMINAL attachment status still rides along on ok:true from both paths" assert.equal(freshResult.ok && !freshResult.alreadyExists && freshResult.attachment, "failed:fault"); const existing = createDeps({ - existingRows: [{ Id: "99", PrivateNote: `note ${marker}` }], + existingRows: [bookedPurchase({ Id: "99", PrivateNote: `note ${marker}` })], attachableRows: [], uploadAttachment: terminal, }); @@ -1408,7 +1484,7 @@ test("the route hands the SAME deadline to the token fetch and the create", asyn }, createPurchase: async (_t, _i, deadline) => { seen.push(deadline); - return { ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const }; + return { ok: true, qbPurchaseId: "p1", docNumber: "doc", alreadyExists: true, attachment: "already-attached" as const, existing: MATCHED as ExistingPurchaseCheck }; }, }); await POST(new Request("https://example.test/api/integrations/qbo-receipts/create", { @@ -1422,6 +1498,543 @@ test("the route hands the SAME deadline to the token fetch and the create", asyn assert.deepEqual(seen[0], seen[1], "both legs share one budget"); assert.equal(seen[0]!.budgetMs, 50_000); }); +// --- The route budget reaches the LATE calls of ensureQBVendor --- + +/** + * ensureQBVendor took a RouteDeadline but only its FIRST query carried it, so + * the candidate scan, the create, and the post-6240 re-query each opened a + * fresh 20s window. A vendor resolve could therefore run well past the route + * ceiling and be killed mid-write, which is the exact failure the shared budget + * exists to prevent. + * + * Each test spends the budget INSIDE the preceding call, then asserts the next + * one is never issued. The no-deadline control in the same test is what makes + * that meaningful: without it the assertion would also pass on code that made + * no call at all. + */ +function vendorFetchStub(options: { burnAtCall: number; duplicateFault?: boolean }) { + const urls: string[] = []; + const impl = (async (url: string | URL, init?: RequestInit) => { + const u = String(url); + urls.push(u); + if (urls.length === options.burnAtCall) { + await new Promise(resolve => setTimeout(resolve, 600)); + } + if (u.includes("/query?query=")) { + // Calls 1 and 2 (exact DisplayName, then the LIKE-prefix scan) miss; + // a fourth query is the post-6240 re-query, which finds the winner. + return new Response(JSON.stringify({ QueryResponse: urls.length > 3 ? { Vendor: [{ Id: "vendor-42" }] } : {} }), { status: 200 }); + } + if (u.includes("/vendor?") && init?.method === "POST") { + return options.duplicateFault + ? new Response('{"Fault":{"Error":[{"code":"6240"}]}}', { status: 400 }) + : new Response(JSON.stringify({ Vendor: { Id: "vendor-1" } }), { status: 200 }); + } + throw new Error(`Unexpected fetch in test: ${u}`); + }) as unknown as typeof fetch; + return { impl, urls }; +} + +/** 1.4s of budget, spent by a 600ms call: the next call starts under the 1s floor. */ +const BUDGET_MS = 1_400; + +test("ensureQBVendor: the candidate scan is refused once the budget is gone", async () => { + const { createRouteDeadline, isQBBudgetExhaustedError } = await import("../src/lib/quickbooks"); + + const spent = vendorFetchStub({ burnAtCall: 1 }); + const error = await withFetch(spent.impl, () => + ensureQBVendor(TOKENS, "Home Depot", createRouteDeadline(BUDGET_MS)), + ).then(() => null, (e: unknown) => e as Error); + assert.ok(isQBBudgetExhaustedError(error), `got ${String(error)}`); + assert.equal(spent.urls.length, 1, "the LIKE-prefix scan must not be issued"); + + // Control: the same stub with no budget reaches the scan. + const control = vendorFetchStub({ burnAtCall: 1 }); + assert.equal(await withFetch(control.impl, () => ensureQBVendor(TOKENS, "Home Depot")), "vendor-1"); + assert.equal(control.urls.length, 3); + assert.match(decodeURIComponent(control.urls[1]), /DisplayName LIKE 'Home%'/); +}); + +test("ensureQBVendor: the create is refused once the budget is gone", async () => { + const { createRouteDeadline, isQBBudgetExhaustedError } = await import("../src/lib/quickbooks"); + + const spent = vendorFetchStub({ burnAtCall: 2 }); + const error = await withFetch(spent.impl, () => + ensureQBVendor(TOKENS, "Home Depot", createRouteDeadline(BUDGET_MS)), + ).then(() => null, (e: unknown) => e as Error); + assert.ok(isQBBudgetExhaustedError(error), `got ${String(error)}`); + assert.equal(spent.urls.length, 2, "no Vendor may be created with no budget left"); + + const control = vendorFetchStub({ burnAtCall: 2 }); + assert.equal(await withFetch(control.impl, () => ensureQBVendor(TOKENS, "Home Depot")), "vendor-1"); + assert.equal(control.urls.length, 3); + assert.match(control.urls[2], /\/vendor\?/); +}); + +test("ensureQBVendor: the post-6240 re-query is refused once the budget is gone", async () => { + const { createRouteDeadline, isQBBudgetExhaustedError } = await import("../src/lib/quickbooks"); + + const spent = vendorFetchStub({ burnAtCall: 3, duplicateFault: true }); + const error = await withFetch(spent.impl, () => + ensureQBVendor(TOKENS, "Home Depot", createRouteDeadline(BUDGET_MS)), + ).then(() => null, (e: unknown) => e as Error); + // The budget, not the duplicate, is the reason — a re-query that never ran + // must not be reported as "no match was found". + assert.ok(isQBBudgetExhaustedError(error), `got ${String(error)}`); + assert.equal(spent.urls.length, 3, "the re-query must not be issued"); + + const control = vendorFetchStub({ burnAtCall: 3, duplicateFault: true }); + assert.equal(await withFetch(control.impl, () => ensureQBVendor(TOKENS, "Home Depot")), "vendor-42"); + assert.equal(control.urls.length, 4); +}); + +// -- An EXISTING Purchase is validated, not assumed (round-34 item 2) -------- + +/** + * The finding: the idempotency query selected `Id, PrivateNote`, so a Purchase + * that was already in the books was treated as interchangeable with the read + * this pass had just done — and book.ts then wrote the Expense from the OCR + * values. A v1-cutover Purchase (the Apps Script posted it from its OWN read) + * or a Drive revision that kept its fileId therefore left ProBuild's job cost + * carrying a total, a date or a job QuickBooks does not have. + */ +const TAX_INPUT = { + projectName: "Mueller Remodel", + vendor: "Home Depot", + date: "2026-07-15", + totalAmount: 150, + groups: [ + { category: "Receipt (pre-tax)", amount: 137.5 }, + { category: "Sales tax", amount: 12.5, tax: true }, + ], +}; + +const readBooked = (over: Record = {}) => + readBookedPurchase(bookedPurchase(over), TAX_ACCOUNT_ID); + +test("the idempotency query asks for the WHOLE Purchase, not two fields", async () => { + const input = baseInput(); + const marker = `[gtr-file:${input.fileId}]`; + const { deps, calls } = createDeps({ existingRows: [bookedPurchase({ PrivateNote: `note ${marker}` })] }); + await createQBReceiptPurchase(TOKENS, input, deps); + // QBO cannot return a nested Line / EntityRef from a field list. + assert.match(calls.queries[0], /^SELECT \* FROM Purchase WHERE DocNumber = /); +}); + +test("a Purchase that agrees is a match, and books unchanged", () => { + const check = compareExistingPurchase(readBooked(), baseInput()); + assert.equal(check.verdict, "match"); + assert.deepEqual(check.differences, []); +}); + +test("AMOUNT: a difference is DERIVED from the books, never taken from the read", () => { + // Real money posted against QBO's number. The disagreement is OCR noise on + // our side, so the books win and the Expense records what was actually paid. + const check = compareExistingPurchase(readBooked({ TotalAmt: 162.75 }), baseInput()); + assert.equal(check.verdict, "derive"); + assert.deepEqual(check.differences, ["amount"]); + assert.equal(check.booked.totalAmount, 162.75); +}); + +test("AMOUNT: a sub-tolerance difference is not a difference at all", () => { + // Two cents, the same tolerance the group/total reconciliation allows: a + // two-line tax split can round each half independently. + for (const total of [150.01, 149.98, 150.02]) { + assert.equal(compareExistingPurchase(readBooked({ TotalAmt: total }), baseInput()).verdict, "match", String(total)); + } + assert.equal(compareExistingPurchase(readBooked({ TotalAmt: 150.03 }), baseInput()).verdict, "derive"); +}); + +test("DATE and VENDOR differences are derived too", () => { + const date = compareExistingPurchase(readBooked({ TxnDate: "2026-07-11" }), baseInput()); + assert.deepEqual([date.verdict, date.differences, date.booked.txnDate], ["derive", ["date"], "2026-07-11"]); + + const vendor = compareExistingPurchase( + readBooked({ EntityRef: { value: "v9", name: "The Home Depot #4712" } }), + baseInput(), + ); + assert.deepEqual([vendor.verdict, vendor.differences], ["derive", ["vendor"]]); + // Case and spacing are not identity. + const same = compareExistingPurchase(readBooked({ EntityRef: { value: "v1", name: " home depot " } }), baseInput()); + assert.equal(same.verdict, "match"); +}); + +test("PROJECT: a different job is a REVIEW — nothing may pick a side automatically", () => { + // Which job carries the cost is an attribution decision, not noise. Deriving + // it would silently move money between jobs; using the read would file it + // under a job the books disagree with. + const check = compareExistingPurchase( + readBooked({ + Line: [{ + Amount: 150, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { value: "cust-9", name: "Mesplay Kitchen" }, + }, + }], + }), + baseInput(), + ); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); + assert.deepEqual(check.booked.projectNames, ["Mesplay Kitchen"]); +}); + +test("PROJECT: lines split across TWO jobs is an ambiguity, and also a review", () => { + const check = compareExistingPurchase( + readBooked({ + Line: [ + { Amount: 75, AccountBasedExpenseLineDetail: { AccountRef: { value: EXPENSE_ACCOUNT_ID }, CustomerRef: { name: "Mueller Remodel" } } }, + { Amount: 75, AccountBasedExpenseLineDetail: { AccountRef: { value: EXPENSE_ACCOUNT_ID }, CustomerRef: { name: "Mesplay Kitchen" } } }, + ], + }), + baseInput(), + ); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.booked.projectNames.sort(), ["Mesplay Kitchen", "Mueller Remodel"]); +}); + +test("TAX: a split the books do not have is a review, not a derive", () => { + // The reseller-permit reclaim is a state filing. Whether this document's + // sales tax is sitting on the reclaimable account is a fact about the books + // that a human has to reconcile, not a number to copy either way. + const noSplit = compareExistingPurchase(readBooked(), TAX_INPUT); + assert.equal(noSplit.verdict, "review"); + assert.deepEqual(noSplit.differences, ["tax"]); + assert.equal(noSplit.booked.taxAmount, 0); + + // The control: the same document against a Purchase that DOES carry the + // split on the tax account books clean. + const split = compareExistingPurchase( + readBooked({ + Line: [ + // BOTH lines carry the customer ID, not just its display name: + // a name is not an identity, and the tax line is money too. + { Amount: 137.5, AccountBasedExpenseLineDetail: { AccountRef: { value: EXPENSE_ACCOUNT_ID }, CustomerRef: { value: "cust-1", name: "Mueller Remodel" } } }, + { Amount: 12.5, AccountBasedExpenseLineDetail: { AccountRef: { value: TAX_ACCOUNT_ID }, CustomerRef: { value: "cust-1", name: "Mueller Remodel" } } }, + ], + }), + TAX_INPUT, + ); + assert.equal(split.verdict, "match"); + assert.equal(split.booked.taxAmount, 12.5); +}); + +test("an UNREADABLE total or date is a review — never a silent pass", () => { + // QBO returns both on every Purchase, so their absence means we are not + // looking at what we think we are. "I could not check" must not read the + // same as "I checked and it agrees" on the path that decides what a real + // Expense records. + for (const over of [{ TotalAmt: undefined }, { TotalAmt: "n/a" }, { TotalAmt: 0 }]) { + const check = compareExistingPurchase(readBooked(over), baseInput()); + assert.equal(check.verdict, "review", JSON.stringify(over)); + assert.ok(check.differences.includes("amount")); + } + for (const over of [{ TxnDate: undefined }, { TxnDate: "07/15/2026" }, { TxnDate: "2026-02-31" }]) { + const check = compareExistingPurchase(readBooked(over), baseInput()); + assert.equal(check.verdict, "review", JSON.stringify(over)); + assert.ok(check.differences.includes("date")); + } +}); + +test("a VENDOR ref with no display name is not comparable, and is not a mismatch", () => { + // QBO documents `name` on a ReferenceType as optional, so its absence is a + // fact about the response shape rather than about the books. The vendor is + // a DERIVE field — QuickBooks wins it outright — so nothing is lost by + // skipping a comparison that cannot be made. + const check = compareExistingPurchase( + readBooked({ EntityRef: { value: "vendor-1" } }), + baseInput(), + ); + assert.equal(check.verdict, "match"); + assert.equal(check.booked.vendor, null); +}); + +test("a CUSTOMER ref with no display name is a REVIEW: the job was never confirmed", () => { + // The job is not a derive field, and this is the half of the old rule that + // was wrong. An id with no name says the lines agree with EACH OTHER; it + // says nothing about whether they agree with this receipt, and the expected + // customer's id is not known on the replay branch (resolving it there would + // CREATE a QBO customer). "I could not check" must not read the same as "I + // checked and it agrees" — the same rule the total and the date already + // follow. + const check = compareExistingPurchase( + readBooked({ + Line: [{ Amount: 150, AccountBasedExpenseLineDetail: { AccountRef: { value: EXPENSE_ACCOUNT_ID }, CustomerRef: { value: "cust-1" } } }], + }), + baseInput(), + ); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); + assert.deepEqual(check.booked.projectNames, []); +}); + +// -- Attribution is PER LINE, not "one line agreed" (round-35 P1) ------------ + +/** + * The finding: the project check ran only `if (projectNames.length > 0)` and + * built that list from the lines that HAD a readable customer name. A Purchase + * that was only partly coded therefore passed on the strength of its coded + * half, and one that was not coded at all skipped the check entirely — and + * book.ts then wrote a real Expense for the WHOLE amount against a job + * QuickBooks does not carry it under. + */ +function linesOf(...lines: Record[]) { + return readBooked({ Line: lines }); +} + +const CODED = { + Amount: 100, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { value: "cust-1", name: "Mueller Remodel" }, + }, +}; +/** The line with no CustomerRef at all: real money on this Purchase, on no job. */ +const UNCODED = { + Amount: 50, + AccountBasedExpenseLineDetail: { AccountRef: { value: EXPENSE_ACCOUNT_ID } }, +}; + +test("ALL lines coded to this job books clean", () => { + const check = compareExistingPurchase(linesOf(CODED, { ...CODED, Amount: 50 }), baseInput()); + assert.equal(check.verdict, "match"); + assert.deepEqual(check.differences, []); +}); + +test("ONE coded line plus an UNCODED one is a review — the old rule called this a match", () => { + // $100 on the job, $50 on nothing, and the Expense would have been written + // for the full $150 against the job. + const check = compareExistingPurchase(linesOf(CODED, UNCODED), baseInput()); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); + assert.deepEqual( + check.booked.lines.map(l => l.customerId), + ["cust-1", null], + "the uncoded line is RECORDED, not dropped — that is what the name set could not do", + ); +}); + +test("ZERO coded lines is a review — the old rule skipped the check entirely", () => { + const check = compareExistingPurchase(linesOf(UNCODED, { ...UNCODED, Amount: 100 }), baseInput()); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); + assert.deepEqual(check.booked.projectNames, [], "nothing to build the old rule's list from"); +}); + +test("all coded, one to a DIFFERENT customer, is a review", () => { + const other = { + Amount: 50, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { value: "cust-9", name: "Mesplay Kitchen" }, + }, + }; + const check = compareExistingPurchase(linesOf(CODED, other), baseInput()); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); +}); + +test("identity is compared on CustomerRef.value, so two customers sharing a NAME is still a review", () => { + // Two QBO customers can carry the same display name — a sub-customer under + // a different parent, a duplicate nobody merged. The name comparison alone + // cannot see it. + const twin = { + Amount: 50, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { value: "cust-2", name: "Mueller Remodel" }, + }, + }; + const check = compareExistingPurchase(linesOf(CODED, twin), baseInput()); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); +}); + +test("an UNCODED TAX line is a REVIEW: reclaimable tax is money on a job too", () => { + // This test used to assert the opposite — that the reclaimable account was + // attribution enough and a tax line needed no customer. It is not: the + // Purchase's whole gross is then recorded against the project the OTHER + // lines name, including a tax split nobody attributed to it. Tax was + // excluded from the "is this line coded at all" guard entirely, so it was + // the one kind of money that could ride along unassigned. + const taxLine = { Amount: 12.5, AccountBasedExpenseLineDetail: { AccountRef: { value: TAX_ACCOUNT_ID } } }; + const uncodedTax = compareExistingPurchase( + readBooked({ + Line: [{ ...CODED, Amount: 137.5 }, taxLine], + }), + TAX_INPUT, + ); + assert.equal(uncodedTax.verdict, "review"); + assert.ok(uncodedTax.differences.includes("project")); + + // THE CONTROL: the same split with the tax line CODED books clean, so this + // is not a rule that simply refuses every tax split. + const ok = compareExistingPurchase( + readBooked({ + Line: [ + { ...CODED, Amount: 137.5 }, + { + Amount: 12.5, + AccountBasedExpenseLineDetail: { + AccountRef: { value: TAX_ACCOUNT_ID }, + CustomerRef: { value: "cust-1", name: "Mueller Remodel" }, + }, + }, + ], + }), + TAX_INPUT, + ); + assert.equal(ok.verdict, "match"); + + const wrongJob = compareExistingPurchase( + readBooked({ + Line: [ + { ...CODED, Amount: 137.5 }, + { + Amount: 12.5, + AccountBasedExpenseLineDetail: { + AccountRef: { value: TAX_ACCOUNT_ID }, + CustomerRef: { value: "cust-9", name: "Mesplay Kitchen" }, + }, + }, + ], + }), + TAX_INPUT, + ); + assert.equal(wrongJob.verdict, "review"); + assert.ok(wrongJob.differences.includes("project")); +}); + +test("a line shape this code cannot read is a review, never a silent skip", () => { + // An item-based expense line, a v1-cutover Purchase, anything without an + // AccountBasedExpenseLineDetail: there is no CustomerRef to find, and the + // money on it is real. + const itemBased = { + Amount: 50, + DetailType: "ItemBasedExpenseLineDetail", + ItemBasedExpenseLineDetail: { ItemRef: { value: "item-1" } }, + }; + const check = compareExistingPurchase(linesOf(CODED, itemBased), baseInput()); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); + assert.equal(check.booked.lines[1].readable, false); +}); + +test("a Purchase with NO lines at all is a review", () => { + const check = compareExistingPurchase(readBooked({ Line: [] }), baseInput()); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["project"]); +}); + +test("a REVIEW outranks a DERIVE when both kinds of difference are present", () => { + const check = compareExistingPurchase( + readBooked({ + TotalAmt: 999, + Line: [{ Amount: 999, AccountBasedExpenseLineDetail: { AccountRef: { value: EXPENSE_ACCOUNT_ID }, CustomerRef: { name: "Mesplay Kitchen" } } }], + }), + baseInput(), + ); + assert.equal(check.verdict, "review"); + assert.deepEqual(check.differences, ["amount", "project"]); +}); + +// ── EVERY monetary line carries the customer id (Codex round-16 item 2) ──── +// +// The old rule let one NAMED line validate a Purchase while other money sat +// unassigned, and the worker then recorded the whole gross against that +// project. Three shapes got through; each is a test below, each with the +// control that proves the rule is not simply "refuse everything". + +test("ONE CODED + ONE UNCODED line is a review, not a match", () => { + // The headline case: real money on this Purchase, on no job at all, waved + // through because a sibling line named the right one. + const mixed = compareExistingPurchase( + readBooked({ Line: [{ ...CODED, Amount: 60 }, { ...UNCODED, Amount: 40 }] }), + baseInput(), + ); + assert.equal(mixed.verdict, "review"); + assert.ok(mixed.differences.includes("project")); +}); + +test("a NAME without an id is not attribution — a name is not an identity", () => { + // The pre-fix hole: the "is this line coded at all" guard was + // `!tax && !customerId && !customerName`, so a display name alone + // satisfied it. QBO's `name` on a ReferenceType is documented optional and + // is a label, not a key. + const namedOnly = { + Amount: 100, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { name: "Mueller Remodel" }, + }, + }; + const check = compareExistingPurchase(readBooked({ Line: [namedOnly] }), baseInput()); + assert.equal(check.verdict, "review"); + assert.ok(check.differences.includes("project")); + + // CONTROL: the same line WITH the id books clean. + const withId = compareExistingPurchase(readBooked({ Line: [{ ...CODED }] }), baseInput()); + assert.equal(withId.verdict, "match"); +}); + +test("ZERO ids across every line is an absence, not agreement", () => { + // `ids.size <= 1` was satisfied by an EMPTY set, so a Purchase whose lines + // carried names and no ids passed with nothing pinned at all. + const allNamed = [0, 1].map(i => ({ + Amount: 50, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { name: "Mueller Remodel" }, + }, + _i: i, + })); + const check = compareExistingPurchase(readBooked({ Line: allNamed }), baseInput()); + assert.equal(check.verdict, "review"); + + // HONEST NOTE ON `ids.size === 1`: the per-line `!line.customerId` guard + // now rejects this shape before the size is ever consulted, so the change + // from `<= 1` to `=== 1` is belt-and-braces rather than an independently + // load-bearing guard — a mutation back to `<= 1` alone changes nothing + // observable. It is written as `=== 1` because that is what the rule + // means, and so that relaxing the per-line guard cannot silently reopen + // the empty-set hole. Asserted here as a source fact, not a behaviour. + const src = readFileSync(path.join(__dirname, "..", "src/lib/qbo-receipt-push.ts"), "utf8"); + assert.match(src, /return ids\.size === 1 && confirmedByName;/); + assert.match(src, /if \(!line\.customerId\) return false;/); +}); + +test("TWO DIFFERENT customer ids is still a review — the split-across-jobs case", () => { + // Unchanged behaviour, asserted so the `=== 1` tightening cannot be read + // as having relaxed anything. + const split = compareExistingPurchase( + readBooked({ + Line: [ + { ...CODED, Amount: 50 }, + { + Amount: 50, + AccountBasedExpenseLineDetail: { + AccountRef: { value: EXPENSE_ACCOUNT_ID }, + CustomerRef: { value: "cust-2", name: "Mueller Remodel" }, + }, + }, + ], + }), + baseInput(), + ); + assert.equal(split.verdict, "review"); +}); + +test("ALL lines coded to one confirmed job is a match — the control", () => { + // Without this, a rule that refused every existing Purchase would pass + // every assertion above. + const ok = compareExistingPurchase( + readBooked({ Line: [{ ...CODED, Amount: 50 }, { ...CODED, Amount: 50 }] }), + baseInput(), + ); + assert.equal(ok.verdict, "match"); + assert.deepEqual(ok.differences, []); +}); // --- The REAL ensure helpers must honour the route budget --- @@ -2231,7 +2844,12 @@ test("two simultaneous pushes of one file upload the attachment ONCE and agree o assert.equal(order.length, 2, "both deliveries went through the per-file lock"); assert.equal(uploads.length, 1, "the receipt image is uploaded exactly once"); - assert.deepEqual(uploads[0], { purchaseId: "77", fileName: "receipt.jpg" }); + // The uploaded name is this branch's STABLE one (fileId-derived), not the + // caller's raw "receipt.jpg"  see the collision test above. + assert.deepEqual(uploads[0], { + purchaseId: "77", + fileName: stableAttachmentFileName(input.fileId, input.fileName), + }); assert.equal(first.ok && first.qbPurchaseId, "77"); assert.equal(second.ok && second.qbPurchaseId, "77"); // One of the two ran first and created; the other found the committed diff --git a/tests/receipt-intake-archive-contract.test.ts b/tests/receipt-intake-archive-contract.test.ts new file mode 100644 index 000000000..68d76abbe --- /dev/null +++ b/tests/receipt-intake-archive-contract.test.ts @@ -0,0 +1,86 @@ +/** + * The contract the nightly Apps Script archive mirror codes against. + * + * The mirror holds a shared secret and NO service key, so it cannot read the + * private bucket on its own. Everything it needs to do its one job — fetch each + * BOOKED receipt and write it to `Processed Receipts/YYYY/MM/` under the v1 + * filename `____$.` — has to be in the + * payload, and nothing else should be. + * + * These assertions are the contract; changing one is a breaking change to a + * script in another repo. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + ARCHIVE_READABLE_STATES, + ARCHIVE_SIGNED_URL_TTL_SECONDS, + RECEIPT_INTAKE_ARCHIVE_SELECT, + RECEIPT_INTAKE_LIST_SELECT, + withArchiveDownloadUrls, +} from "../src/lib/receipt-intake/queries"; + +test("the archive payload carries everything the v1 filename is built from", () => { + for (const field of ["txnDate", "vendor", "totalCents", "refNumber", "fileName", "mimeType"]) { + assert.ok(field in RECEIPT_INTAKE_ARCHIVE_SELECT, `missing ${field}`); + } + // The project NAME, not an id the script cannot resolve. + assert.ok("project" in RECEIPT_INTAKE_ARCHIVE_SELECT); + // And what it needs to report back and to skip already-archived rows. + for (const field of ["id", "state", "archiveDriveFileId", "storagePath"]) { + assert.ok(field in RECEIPT_INTAKE_ARCHIVE_SELECT, `missing ${field}`); + } +}); + +test("the archive payload withholds everything the mirror has no business seeing", () => { + // Least privilege applies to a script the same way it does to a user: a + // leaked or over-shared secret should expose as little as still works. + for (const field of ["lastError", "fileSha256", "createdById", "readJson", "dedupStrongKey", "dedupWeakKey", "attempts", "busyPasses"]) { + assert.ok(!(field in RECEIPT_INTAKE_ARCHIVE_SELECT), `${field} must not be exposed`); + // ...and it IS in the staff select, so this is a real narrowing rather + // than a column that simply does not exist. + if (field !== "readJson") { + assert.ok(field in RECEIPT_INTAKE_LIST_SELECT, `${field} should exist on the staff select`); + } + } +}); + +test("the mirror may only ask for the two states it acts on", () => { + assert.deepEqual([...ARCHIVE_READABLE_STATES].sort(), ["ARCHIVED", "BOOKED"]); +}); + +test("each row gets a short-lived signed URL and a flat project name", async () => { + const signed: Array<{ ref: string; ttl: number }> = []; + const rows = await withArchiveDownloadUrls( + [ + { id: "a", storagePath: "receipts/intake/a.jpg", project: { name: "Berg ADU" } }, + { id: "b", storagePath: "receipts/intake/b.pdf", project: null }, + ], + async (ref: string, ttl: number) => { signed.push({ ref, ttl }); return `https://signed.test/${ref}`; }, + ); + + assert.equal(rows[0].projectName, "Berg ADU"); + assert.equal(rows[0].downloadUrl, "https://signed.test/receipts/intake/a.jpg"); + assert.equal(rows[1].projectName, null, "a project-less row is still archivable"); + // The nested relation is flattened away — the script gets `projectName`. + assert.ok(!("project" in rows[0])); + + // A private bucket plus a per-request grant: the script never holds a key, + // and a URL captured from a log is useless by morning. + assert.equal(ARCHIVE_SIGNED_URL_TTL_SECONDS, 600); + assert.deepEqual(signed.map(s => s.ttl), [600, 600]); + // The receipts bucket is named by the signer, so what it is handed is the + // PATH — an object in another bucket cannot be reached from here at all. + assert.deepEqual(signed.map(s => s.ref), ["receipts/intake/a.jpg", "receipts/intake/b.pdf"]); +}); + +test("a row whose URL cannot be signed is returned with null, never dropped", async () => { + // A silently short archive is worse than a logged gap. + const rows = await withArchiveDownloadUrls( + [{ id: "a", storagePath: "receipts/intake/a.jpg", project: { name: "Berg ADU" } }], + async () => null, + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].downloadUrl, null); + assert.equal(rows[0].projectName, "Berg ADU"); +}); diff --git a/tests/receipt-intake-auth.test.ts b/tests/receipt-intake-auth.test.ts new file mode 100644 index 000000000..d5a5749b6 --- /dev/null +++ b/tests/receipt-intake-auth.test.ts @@ -0,0 +1,820 @@ +/** + * The intake endpoint's auth boundary, at the two places it can fail open: + * the proxy's bypass set, and the shared-secret comparison. + * + * The bypass is what makes the handler the ONLY gate, so its shape is a + * security assertion: exact paths, no descendants. A wildcard here would + * pre-authorize any future /api/receipts/* route the moment someone creates + * the file — which is the mistake the office-tasks comment already warns about. + * + * src/proxy.ts statically imports @/lib/staff-status (prisma), so the env those + * modules expect is set before the dynamic import; nothing here hits a database. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +process.env.NEXTAUTH_SECRET ??= "test-secret"; +process.env.DATABASE_URL ??= "postgresql://test:test@localhost:5432/test"; + +const loadProxy = () => import("../src/proxy"); +const loadAuth = () => import("../src/lib/receipt-intake/intake-auth"); +const loadFileType = () => import("../src/lib/receipt-intake/file-type"); + +test("the intake paths bypass the proxy so machine callers get a clean 401", async () => { + const { isPublicProxyBypass } = await loadProxy(); + for (const path of [ + "/api/receipts/intake", + "/api/receipts/intake/", + "/api/receipts/intake/abc123/archived", + "/api/receipts/intake/abc123/archived/", + ]) { + assert.equal(isPublicProxyBypass(path), true, path); + } +}); + +test("the bypass does NOT widen to descendants or to the rest of /api/receipts", async () => { + const { isPublicProxyBypass } = await loadProxy(); + for (const path of [ + "/api/receipts/intake/abc123", // a future detail route + "/api/receipts/intake/abc123/anything", // a future sub-route + "/api/receipts/intake/abc123/archived/x", // deeper than the callback + "/api/receipts/parse", // the v1 AI parser keeps the proxy + "/api/receipts", + "/api/receipts-intake", // no dash-for-slash confusion + ]) { + assert.equal(isPublicProxyBypass(path), false, path); + } +}); + +test("the secret check fails CLOSED when the env var is unset or empty", async () => { + const { secretMatches } = await loadAuth(); + // The getclients-auth-gate lesson: an unset secret must refuse, never allow. + assert.equal(secretMatches("anything", undefined), false); + assert.equal(secretMatches("", undefined), false); + assert.equal(secretMatches("", ""), false); + assert.equal(secretMatches(null, "real-secret"), false); + assert.equal(secretMatches("wrong", "real-secret"), false); + assert.equal(secretMatches("real-secret", "real-secret"), true); +}); + +test("the stored mime is decided on the BYTES, not the caller's header", async () => { + const { sniffMime } = await loadFileType(); + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const gif = Buffer.from("GIF89a-----"); + const webp = Buffer.concat([Buffer.from("RIFF"), Buffer.from([0, 0, 0, 0]), Buffer.from("WEBP")]); + const pdf = Buffer.from("%PDF-1.7\n..."); + const ftyp = (brand: string) => Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from(`ftyp${brand}`)]); + + // A lie in the header cannot change the answer. + assert.equal(sniffMime(jpeg, "text/plain"), "image/jpeg"); + assert.equal(sniffMime(png, "application/pdf"), "image/png"); + assert.equal(sniffMime(gif, "image/jpeg"), "image/gif"); + assert.equal(sniffMime(webp, "image/png"), "image/webp"); + assert.equal(sniffMime(pdf, "image/png"), "application/pdf"); + // ISO/IEC 23008-12 major brands. iPhones emit `heic`/`heix` for stills and + // the HEVC brands for a burst or a Live Photo still — an earlier `hei` + // prefix check silently refused hevc/hevx, so those uploads came back + // "unsupported-file-type" from a perfectly readable photo. + for (const brand of ["heic", "heix", "hevc", "hevx", "msf1"]) { + assert.equal(sniffMime(ftyp(brand), "image/jpeg"), "image/heic", brand); + } + // The generic HEIF brands keep their own content type. + for (const brand of ["mif1", "heif"]) { + assert.equal(sniffMime(ftyp(brand), "image/jpeg"), "image/heif", brand); + } + // An unrelated ftyp box (an MP4) is not a receipt. + assert.equal(sniffMime(ftyp("isom"), "image/heic"), null); + + // text/plain is REFUSED outright now: QuickBooks cannot attach a .txt, so + // accepting one meant reading it and then stranding it unbookable. + assert.equal(sniffMime(Buffer.from("VENDOR: Lowes"), "text/plain; charset=utf-8"), null); + // Anything unrecognised, and every empty file, is refused. + assert.equal(sniffMime(Buffer.from("MZ\x90\x00"), "application/pdf"), null); + assert.equal(sniffMime(Buffer.alloc(0), "text/plain"), null); +}); + +test("a Next-Action dispatch on a bypassed intake path is 403, not waved through", async () => { + // Phase 2's Codex review: the bypass returned NextResponse.next() for ANY + // request, including one carrying a `next-action` header. Next's action IDs + // are global, so such a POST invokes SOMEONE ELSE'S action and never reaches + // this route's code — meaning the in-handler x-receipt-intake-secret check, + // which is the only gate these paths have, never runs. A machine caller + // carries no session cookie, so the stale-cookie guard does not cover it + // either. Bypassing the proxy must never also bypass the action boundary. + const { default: proxy } = await loadProxy(); + const { NextRequest } = await import("next/server"); + const event = { waitUntil() {} } as any; + // The proxy short-circuits to next() in development, so the real path is + // only reachable with NODE_ENV=production. + // NODE_ENV is typed read-only; the proxy reads it at call time, so a cast + // is the only way to exercise the non-development branch here. + const env = process.env as Record; + const prod = env.NODE_ENV; + env.NODE_ENV = "production"; + + try { + for (const path of [ + "/api/receipts/intake", + "/api/receipts/intake/", + "/api/receipts/intake/abc123/archived", + ]) { + const res = await proxy( + new NextRequest(`https://probuild.test${path}`, { + method: "POST", + headers: { "next-action": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }, + }), + event, + ); + assert.ok(res, `${path} produced no response`); + assert.equal(res.status, 403, `${path} must refuse an action dispatch`); + // NextResponse.next() carries x-middleware-next: 1. Anything else + // means the proxy kept control, which is the point. + assert.equal(res.headers.get("x-middleware-next"), null, path); + } + + // A NORMAL request on the same paths still gets the bypass, so the + // machine callers this route exists for are unaffected. + const normal = await proxy( + new NextRequest("https://probuild.test/api/receipts/intake", { + method: "POST", + headers: { "x-receipt-intake-secret": "whatever" }, + }), + event, + ); + assert.ok(normal, "the normal request produced no response"); + assert.equal(normal.headers.get("x-middleware-next"), "1", "the bypass still works without next-action"); + } finally { + env.NODE_ENV = prod; + } +}); + +test("the two-step upload paths bypass the proxy, exactly", async () => { + const { isPublicProxyBypass } = await loadProxy(); + for (const path of [ + "/api/receipts/intake/start", + "/api/receipts/intake/start/", + "/api/receipts/intake/abc123/finalize", + "/api/receipts/intake/abc123/finalize/", + ]) { + assert.equal(isPublicProxyBypass(path), true, path); + } + // ...and no wider than that. + for (const path of [ + "/api/receipts/intake/start/extra", + "/api/receipts/intake/abc123/finalize/extra", + "/api/receipts/intake/abc123", + "/api/receipts/intake/abc123/other", + ]) { + assert.equal(isPublicProxyBypass(path), false, path); + } +}); + +test("a Next-Action dispatch is refused on the two-step paths too", async () => { + // Same reasoning as the single-shot route: these bypass the proxy, so the + // in-handler secret/session check is their ONLY gate, and an action dispatch + // never reaches the handler at all. + const { default: proxy } = await loadProxy(); + const { NextRequest } = await import("next/server"); + const event = { waitUntil() {} } as any; + const env = process.env as Record; + const prod = env.NODE_ENV; + env.NODE_ENV = "production"; + try { + for (const path of ["/api/receipts/intake/start", "/api/receipts/intake/abc123/finalize"]) { + const res = await proxy( + new NextRequest(`https://probuild.test${path}`, { + method: "POST", + headers: { "next-action": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }, + }), + event, + ); + assert.ok(res, path); + assert.equal(res.status, 403, `${path} must refuse an action dispatch`); + assert.equal(res.headers.get("x-middleware-next"), null, path); + } + // A normal machine POST still passes through. + const normal = await proxy( + new NextRequest("https://probuild.test/api/receipts/intake/start", { + method: "POST", + headers: { "x-receipt-intake-secret": "whatever" }, + }), + event, + ); + assert.equal(normal!.headers.get("x-middleware-next"), "1"); + } finally { + env.NODE_ENV = prod; + } +}); + +test("provenance rules are shared by BOTH upload paths", async () => { + // decideSource is the single implementation, so the two-step flow cannot + // drift into accepting a caller-chosen source or sourceRef. + const { decideSource, MAX_INLINE_UPLOAD_BYTES, MAX_STORED_BYTES } = + await import("../src/lib/receipt-intake/intake-core"); + + const session = { ok: true, via: "session", userVia: "next-auth", user: { id: "u1", role: "ADMIN" } } as any; + assert.deepEqual(decideSource(session, { source: "drive" }), { ok: false, reason: "invalid-source" }); + assert.deepEqual(decideSource(session, { sourceRef: "web:x" }), { ok: false, reason: "sourceRef-not-allowed" }); + assert.deepEqual(decideSource(session, { uploadId: "nope" }), { ok: false, reason: "invalid-uploadId" }); + + const scoped = decideSource(session, { uploadId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301" }); + assert.ok(scoped.ok); + assert.equal(scoped.sourceRef, "web:u1:3f2504e0-4f89-41d3-9a0c-0305e82c3301", "scoped to the USER"); + + const mobile = { ok: true, via: "session", userVia: "mobile-jwt", user: { id: "u2", role: "FIELD_CREW" } } as any; + // No uploadId AND no checksum: refused rather than minting a random, + // non-idempotent key — see "a bare retry with no uploadId..." below. + assert.deepEqual(decideSource(mobile, {}), { ok: false, reason: "missing-idempotency-key" }); + + const secret = { + ok: true, via: "secret", user: null, userVia: null, + capability: "ingest", allowedSources: new Set(["drive", "email", "chat"]), + } as any; + assert.deepEqual(decideSource(secret, { source: "chat", sourceRef: "drive:x" }), + { ok: false, reason: "sourceRef-namespace-mismatch" }); + assert.deepEqual(decideSource(secret, { source: "web", sourceRef: "web:x" }), + { ok: false, reason: "invalid-source" }); + // A REAL Drive file id shape. The old rule accepted any tail at all, + // including none. + assert.ok(decideSource(secret, { source: "drive", sourceRef: "drive:1AbCdEfGhIjKlMnOp_qR" }).ok); + + // The inline body cap is well under the stored cap, which is the whole + // reason the two-step path exists. + assert.ok(MAX_INLINE_UPLOAD_BYTES < MAX_STORED_BYTES); + // The stored ceiling is QuickBooks' attachment ceiling — see + // tests/apply-receipt-intake.test.ts, which ties it to the bucket policy + // and the booking preflight. + assert.equal(MAX_STORED_BYTES, 8 * 1024 * 1024); +}); + +// ── A bare retry with no uploadId is still idempotent (round-31 gate, 3) ─── + +test("a session/mobile caller with no uploadId is keyed by content, not a random mint", async () => { + const { decideSource } = await import("../src/lib/receipt-intake/intake-core"); + const mobile = { ok: true, via: "session", userVia: "mobile-jwt", user: { id: "u2", role: "FIELD_CREW" } } as any; + const web = { ok: true, via: "session", userVia: "next-auth", user: { id: "u3", role: "ADMIN" } } as any; + + // Neither an uploadId nor a checksum: there is nothing to derive a durable + // key from, so this is refused rather than minted at random. + assert.deepEqual(decideSource(mobile, {}), { ok: false, reason: "missing-idempotency-key" }); + assert.deepEqual(decideSource(mobile, { checksum: "" }), { ok: false, reason: "missing-idempotency-key" }); + assert.deepEqual(decideSource(mobile, { checksum: "not-hex" }), { ok: false, reason: "missing-idempotency-key" }); + // Too short to be a real sha256, even though every character is hex. + assert.deepEqual(decideSource(mobile, { checksum: "ab".repeat(16) }), { ok: false, reason: "missing-idempotency-key" }); + + const checksum = "a".repeat(64); + const first = decideSource(mobile, { checksum }); + assert.ok(first.ok); + assert.equal(first.sourceRef, `session:u2:${checksum}`, "scoped to the USER, not just the content"); + // SAME user, SAME bytes, called again — the exact scenario a retried + // upload with no client token produces. THE SAME sourceRef is the whole + // point: it is what makes the second POST collide with the row the first + // one already created instead of minting a second one. + assert.deepEqual(decideSource(mobile, { checksum }), first); + + // Uppercase hex is normalised the same way as an uploadId is lowercased. + assert.deepEqual(decideSource(mobile, { checksum: checksum.toUpperCase() }), first); + + // Different user, same bytes -> a DIFFERENT key. Two people photographing + // the same physical receipt must not collide with each other. + const other = decideSource(web, { checksum }); + assert.ok(other.ok); + assert.notEqual(other.sourceRef, first.sourceRef); + + // uploadId still wins when the caller supplies one — behaviour unchanged + // from before this fix, checksum or not. + const uploadId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + const withUploadId = decideSource(mobile, { uploadId, checksum }); + assert.ok(withUploadId.ok); + assert.equal(withUploadId.sourceRef, `mobile:u2:${uploadId}`); +}); + +// ── Two secrets, two blast radii (Phase 3 gate, c) ───────────────────────── + +test("each secret may only do its own job; cross-use is 403", async () => { + const { authenticateIntake, INGEST_ALLOWED_SOURCES } = await loadAuth(); + const env = process.env as Record; + const before = { i: env.RECEIPT_INTAKE_SECRET, a: env.RECEIPT_ARCHIVE_SECRET }; + env.RECEIPT_INTAKE_SECRET = "ingest-key"; + env.RECEIPT_ARCHIVE_SECRET = "archive-key"; + const req = (secret: string) => + new Request("https://probuild.test/api/receipts/intake", { + method: "POST", + headers: { "x-receipt-intake-secret": secret }, + }); + + try { + // Right key, right job. + const ingesting = await authenticateIntake(req("ingest-key"), "ingest"); + assert.ok(ingesting.ok); + assert.equal(ingesting.via, "secret"); + if (ingesting.via !== "secret") throw new Error("unreachable"); + assert.equal(ingesting.capability, "ingest"); + assert.deepEqual([...ingesting.allowedSources].sort(), ["chat", "drive", "email"]); + + const archiving = await authenticateIntake(req("archive-key"), "archive"); + assert.ok(archiving.ok); + if (archiving.via !== "secret") throw new Error("unreachable"); + assert.equal(archiving.capability, "archive"); + assert.equal(archiving.allowedSources.size, 0, "the mirror declares no sources at all"); + + // Cross-use: authenticated, but holding the OTHER program's key. 403, + // not 401 — saying so is what makes a mis-wired script obvious rather + // than looking like a rotation problem. + const forwarderReadingTheQueue = await authenticateIntake(req("ingest-key"), "archive"); + assert.equal(forwarderReadingTheQueue.ok, false); + assert.equal((forwarderReadingTheQueue as { response: Response }).response.status, 403); + + const mirrorInjectingReceipts = await authenticateIntake(req("archive-key"), "ingest"); + assert.equal(mirrorInjectingReceipts.ok, false); + assert.equal((mirrorInjectingReceipts as { response: Response }).response.status, 403); + + // An unknown secret is 401, not 403 — it is not authenticated at all. + const stranger = await authenticateIntake(req("neither"), "ingest"); + assert.equal((stranger as { response: Response }).response.status, 401); + + assert.deepEqual([...INGEST_ALLOWED_SOURCES].sort(), ["chat", "drive", "email"]); + } finally { + env.RECEIPT_INTAKE_SECRET = before.i; + env.RECEIPT_ARCHIVE_SECRET = before.a; + } +}); + +test("configuring ONE value for both variables is refused, not silently merged", async () => { + // Otherwise the split is undone by a copy-paste and nobody finds out. + const { authenticateIntake } = await loadAuth(); + const env = process.env as Record; + const before = { i: env.RECEIPT_INTAKE_SECRET, a: env.RECEIPT_ARCHIVE_SECRET }; + env.RECEIPT_INTAKE_SECRET = "same"; + env.RECEIPT_ARCHIVE_SECRET = "same"; + try { + const res = await authenticateIntake( + new Request("https://probuild.test/api/receipts/intake", { + method: "POST", + headers: { "x-receipt-intake-secret": "same" }, + }), + "ingest", + ); + assert.equal(res.ok, false); + assert.equal((res as { response: Response }).response.status, 401); + } finally { + env.RECEIPT_INTAKE_SECRET = before.i; + env.RECEIPT_ARCHIVE_SECRET = before.a; + } +}); + +test("an unset secret refuses that capability — never fails open", async () => { + const { authenticateIntake } = await loadAuth(); + const env = process.env as Record; + const before = { i: env.RECEIPT_INTAKE_SECRET, a: env.RECEIPT_ARCHIVE_SECRET }; + delete env.RECEIPT_INTAKE_SECRET; + delete env.RECEIPT_ARCHIVE_SECRET; + try { + for (const need of ["ingest", "archive"] as const) { + const res = await authenticateIntake( + new Request("https://probuild.test/api/receipts/intake", { + method: "POST", + headers: { "x-receipt-intake-secret": "anything" }, + }), + need, + ); + assert.equal(res.ok, false, need); + assert.equal((res as { response: Response }).response.status, 401, need); + } + } finally { + env.RECEIPT_INTAKE_SECRET = before.i; + env.RECEIPT_ARCHIVE_SECRET = before.a; + } +}); + +test("ONLY ONE secret configured refuses BOTH capabilities — never accepts the one that IS set", async () => { + // The regression: `secretMatches(provided, undefined)` is false for every + // input, so with only RECEIPT_INTAKE_SECRET set, a caller presenting it + // sailed straight through — not because it held real archive authority, + // but because there was no archive secret to fail the OTHER compare + // against either. The invariant has to be checked on the env vars + // themselves, not merely inferred from "both compares matched". + const { authenticateIntake } = await loadAuth(); + const env = process.env as Record; + const before = { i: env.RECEIPT_INTAKE_SECRET, a: env.RECEIPT_ARCHIVE_SECRET }; + const req = (secret: string) => + new Request("https://probuild.test/api/receipts/intake", { + method: "POST", + headers: { "x-receipt-intake-secret": secret }, + }); + + try { + // Only RECEIPT_INTAKE_SECRET set. + env.RECEIPT_INTAKE_SECRET = "ingest-key"; + delete env.RECEIPT_ARCHIVE_SECRET; + for (const need of ["ingest", "archive"] as const) { + const res = await authenticateIntake(req("ingest-key"), need); + assert.equal(res.ok, false, `intake-only, need=${need}`); + assert.equal((res as { response: Response }).response.status, 401, `intake-only, need=${need}`); + } + + // Only RECEIPT_ARCHIVE_SECRET set. + delete env.RECEIPT_INTAKE_SECRET; + env.RECEIPT_ARCHIVE_SECRET = "archive-key"; + for (const need of ["ingest", "archive"] as const) { + const res = await authenticateIntake(req("archive-key"), need); + assert.equal(res.ok, false, `archive-only, need=${need}`); + assert.equal((res as { response: Response }).response.status, 401, `archive-only, need=${need}`); + } + } finally { + env.RECEIPT_INTAKE_SECRET = before.i; + env.RECEIPT_ARCHIVE_SECRET = before.a; + } +}); + +test("BOTH secrets set to the SAME value refuses BOTH capabilities, whatever is presented", async () => { + // The "configuring ONE value for both variables" test above only drives + // the case where the caller happens to present that shared value. This + // pins the invariant independently of what's on the wire: an unrelated + // wrong guess must not be waved through by way of "the equal-secret check + // never even ran". + const { authenticateIntake } = await loadAuth(); + const env = process.env as Record; + const before = { i: env.RECEIPT_INTAKE_SECRET, a: env.RECEIPT_ARCHIVE_SECRET }; + env.RECEIPT_INTAKE_SECRET = "shared"; + env.RECEIPT_ARCHIVE_SECRET = "shared"; + try { + for (const need of ["ingest", "archive"] as const) { + const res = await authenticateIntake( + new Request("https://probuild.test/api/receipts/intake", { + method: "POST", + headers: { "x-receipt-intake-secret": "shared" }, + }), + need, + ); + assert.equal(res.ok, false, need); + assert.equal((res as { response: Response }).response.status, 401, need); + } + } finally { + env.RECEIPT_INTAKE_SECRET = before.i; + env.RECEIPT_ARCHIVE_SECRET = before.a; + } +}); + +test("a secret may only declare the sources ITS key owns", async () => { + const { decideSource } = await import("../src/lib/receipt-intake/intake-core"); + const ingest = { + ok: true, via: "secret", user: null, userVia: null, + capability: "ingest", allowedSources: new Set(["drive", "email", "chat"]), + } as any; + const archive = { + ok: true, via: "secret", user: null, userVia: null, + capability: "archive", allowedSources: new Set(), + } as any; + + assert.ok(decideSource(ingest, { source: "drive", sourceRef: "drive:1AbCdEfGhIjKlMnOp_qR" }).ok); + // The archive key owns no sources, so it can never mint an intake row even + // if it somehow reached this code. + assert.deepEqual(decideSource(archive, { source: "drive", sourceRef: "drive:1AbCdEfGhIjKlMnOp_qR" }), + { ok: false, reason: "invalid-source" }); +}); + +// ── The worker cron gate (Phase 2 gate, a) ───────────────────────────────── + +test("the intake worker cron route uses the shared fail-closed gate", async () => { + // Source assertion, because the hole was a BRANCH rather than a wrong + // comparison: `!VERCEL && NODE_ENV !== "production" && !CRON_SECRET` is + // satisfied by an UNSET environment, so any container or drifted preview + // served this route — which books real money into QuickBooks — to anyone. + const { readFileSync } = await import("node:fs"); + const raw = readFileSync( + new URL("../src/app/api/cron/receipt-intake-worker/route.ts", import.meta.url), + "utf8", + ); + // Comments stripped: the route DESCRIBES the hole it closed, and a naive + // scan reads that description as the hole itself. + const route = raw + .replace(/\/\*[\s\S]*?\*\//g, " ") + .split("\n") + .map(line => line.replace(/\/\/.*/, "")) + .join("\n"); + + assert.match(route, /isCronAuthorized\(request\)/, "uses the shared gate"); + assert.ok(!/isLocalDev/.test(route), "the fail-open branch is gone"); + assert.ok(!/authHeader === `Bearer/.test(route), "no plain string compare on a secret"); + assert.ok(!/process\.env\.VERCEL\b/.test(route), "no environment escape hatch"); + assert.ok(!/process\.env\.CRON_SECRET/.test(route), "the secret is read only by the shared helper"); +}); + +test("isCronAuthorized fails closed on an unset secret and is constant-time", async () => { + const { isCronAuthorized, bearerMatches } = await import("../src/lib/cron-auth"); + const env = process.env as Record; + const before = { s: env.CRON_SECRET, n: env.NODE_ENV }; + const req = (auth?: string) => + new Request("https://probuild.test/api/cron/receipt-intake-worker", { + headers: auth ? { authorization: auth } : {}, + }); + try { + env.NODE_ENV = "production"; + + // No secret configured: refuse, rather than treat "unconfigured" as open. + delete env.CRON_SECRET; + assert.equal(isCronAuthorized(req("Bearer anything")), false); + assert.equal(isCronAuthorized(req()), false); + + env.CRON_SECRET = "s3cret"; + assert.equal(isCronAuthorized(req("Bearer s3cret")), true); + assert.equal(isCronAuthorized(req("Bearer wrong")), false); + assert.equal(isCronAuthorized(req("s3cret")), false, "the scheme is part of the match"); + assert.equal(isCronAuthorized(req()), false); + + // Length is compared before the bytes, so a wrong-length header cannot + // throw out of timingSafeEqual. + assert.equal(bearerMatches("Bearer s3cre", "s3cret"), false); + assert.equal(bearerMatches("Bearer s3cretttt", "s3cret"), false); + assert.equal(bearerMatches(null, "s3cret"), false); + assert.equal(bearerMatches("Bearer s3cret", undefined), false); + } finally { + env.CRON_SECRET = before.s; + env.NODE_ENV = before.n; + } +}); + +test("ANONYMOUS action dispatch: allowlisted paths pass, everything else is 403", async () => { + // 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. So every + // public-bypass path was an anonymous dispatcher for any action in the app, + // and the old denylist (legal pages + machine endpoints) closed the two + // somebody had thought of while /api/auth, /api/mobile, /api/pdf/*, /login, + // /share/* and the asset patterns stayed open. + // + // These are RUNTIME dispatches through the real proxy, not assertions about + // a helper: the bug was an ORDERING one (the bypass returned next() before + // any action check ran), and only driving the request end to end can see it. + const { default: proxy } = await loadProxy(); + const { NextRequest } = await import("next/server"); + const event = { waitUntil() {} } as any; + const env = process.env as Record; + const prod = env.NODE_ENV; + env.NODE_ENV = "production"; + + // A dispatch may carry the tree's own session cookie. Round 49 (main) made + // that PRESENCE the condition for dispatching through a bypassed tree: an + // action id is a public build artefact, not an authorization token, so + // `/portal` being reachable must not mean anybody may run any action there. + const dispatch = (p: string, cookie?: string) => + proxy(new NextRequest(`https://probuild.test${p}`, { + method: "POST", + headers: cookie + ? { "next-action": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", cookie } + : { "next-action": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }, + }), event); + + try { + // REFUSED — none of these define an anonymous Server Action, and each + // one bypasses the proxy for its own unrelated reason. + for (const path of [ + // Machine endpoints: their only gate is a secret checked in the + // handler, which an action dispatch never reaches. + "/api/cron/receipt-intake-worker", + "/api/health/pipeline", + "/api/integrations/qbo-receipts/create", + "/api/webhook/stripe", + "/api/twilio/sms", + "/api/receipts/intake", + "/api/receipts/intake/start", + "/api/receipts/intake/abc123/finalize", + // Public bypasses the old denylist never mentioned. These are the + // regression: every one of them dispatched actions anonymously. + "/api/auth/session", + "/api/mobile/projects", + "/api/pdf/estimates/abc123", + "/api/portal/verify", + "/api/payments/deposit-ingest", + "/api/selections/item-comments", + "/login", + "/share/room/sometoken", + // Legal pages, as before. + "/privacy", + "/terms", + "/account-deletion", + "/support", + ]) { + const res = await dispatch(path); + assert.ok(res, path); + assert.equal(res.status, 403, `${path} must refuse an anonymous action dispatch`); + // NextResponse.next() carries x-middleware-next: 1. Anything else + // means the proxy kept control, which is the point. + assert.equal(res.headers.get("x-middleware-next"), null, path); + } + + // ALLOWED — the client portal and the sub portal genuinely dispatch + // actions with no session (approveEstimate, markInvoiceViewed, + // subPortalUploadCOI, the sub sign-in flow). Each authorizes on its own + // client/token check INSIDE the action; 403ing them here would break + // the portal outright. + for (const path of [ + "/portal", + "/portal/estimates/cmpd8mblp0004od6iufe0jfzc", + "/portal/invoices/abc123", + "/portal/projects/abc123/selections", + "/portal/clip", + "/sub-portal", + "/sub-portal/login", + "/sub-portal/projects/abc123", + ]) { + const cookie = path.startsWith("/sub-portal") + ? "sub_portal_token=t" + : "client_portal_token=t"; + const res = await dispatch(path, cookie); + assert.equal(res!.headers.get("x-middleware-next"), "1", `${path} must still dispatch`); + // ...and the SAME path with no session evidence is refused. + assert.equal((await dispatch(path))!.status, 403, `${path} must refuse an anonymous dispatch`); + } + + // The allowlist is a PREFIX of path segments, not a substring: a route + // that merely starts with the same letters is not the portal. None of + // these is a public bypass, so the staff auth matcher answers instead of + // the action check  a 307 to /login rather than a 403. Either way the + // proxy KEEPS CONTROL, which is the property that matters: the dispatch + // must never be waved through to the action. + for (const path of ["/portalx", "/sub-portalx", "/api/portal-ish"]) { + const res = await dispatch(path); + assert.equal(res!.headers.get("x-middleware-next"), null, path); + assert.notEqual(res!.status, 200, path); + } + + // And an ordinary request — no next-action header — is untouched on + // every one of those paths. + for (const path of ["/api/cron/receipt-intake-worker", "/api/receipts/intake", "/login", "/share/room/t"]) { + const res = await proxy(new NextRequest(`https://probuild.test${path}`, { method: "GET" }), event); + assert.equal(res!.headers.get("x-middleware-next"), "1", `${path} without the header`); + } + } finally { + env.NODE_ENV = prod; + } +}); + +test("a sourceRef must carry a real id for its source, not just the prefix", async () => { + const { validateSourceRef, decideSource, MAX_SOURCE_REF_BYTES } = + await import("../src/lib/receipt-intake/intake-core"); + + // THE REGRESSION: `drive:` with an empty tail was a valid, unique, + // PERMANENT idempotency key. Every later empty-tail forward collided with + // it and was answered "already received", so real receipts were dropped — + // and for Drive the tail is also the QuickBooks DocNumber seed. + for (const source of ["drive", "email", "chat"]) { + assert.deepEqual( + validateSourceRef(source, `${source}:`), + { ok: false, reason: "invalid-sourceRef" }, + source, + ); + } + + // Oversized: this value lands in a unique index, in logs, and in + // QuickBooks-facing identity. + const long = `drive:${"a".repeat(MAX_SOURCE_REF_BYTES)}`; + assert.deepEqual(validateSourceRef("drive", long), { ok: false, reason: "sourceRef-too-long" }); + assert.equal(MAX_SOURCE_REF_BYTES, 512); + + // Shape, per source. + assert.deepEqual(validateSourceRef("drive", "drive:1AbCdEfGhIjKlMnOp_qR"), { ok: true }); + assert.deepEqual(validateSourceRef("drive", "drive:short"), { ok: false, reason: "invalid-sourceRef" }); + assert.deepEqual(validateSourceRef("drive", "drive:has spaces here"), { ok: false, reason: "invalid-sourceRef" }); + // THE PRODUCTION FORMATS, exactly as the Apps Script forwarder sends them. + assert.deepEqual(validateSourceRef("email", "email:1993f0a3c9c4d0d2:0f1e2d3c4b5a6978"), { ok: true }); + assert.deepEqual( + validateSourceRef("email", "email:1993f0a3c9c4d0d2"), + { ok: false, reason: "invalid-sourceRef" }, + "one message can carry several receipts; the content hash is part of the identity", + ); + assert.deepEqual( + validateSourceRef("email", "email:1993f0a3c9c4d0d2:NOTHEX0123456789"), + { ok: false, reason: "invalid-sourceRef" }, + "the tail is a sha16, not free text", + ); + assert.deepEqual( + validateSourceRef("chat", "chat:spaces/AAQANF47osY/messages/abc.def:0"), + { ok: true }, + ); + assert.deepEqual( + validateSourceRef("chat", "chat:spaces/AAQANF47osY/messages/abc.def"), + { ok: false, reason: "invalid-sourceRef" }, + "the attachment index is part of the identity", + ); + assert.deepEqual( + validateSourceRef("chat", "chat:AAQANF47osY"), + { ok: false, reason: "invalid-sourceRef" }, + "a bare space id is not a message", + ); + + // A control character is never part of an id, whatever the source. + assert.deepEqual( + validateSourceRef("drive", "drive:1AbCdEfGhIjKlMnOp\u0000qR"), + { ok: false, reason: "invalid-sourceRef" }, + ); + + // And BOTH entry points get it, because decideSource is where it is applied. + const secret = { + ok: true, via: "secret", user: null, userVia: null, + capability: "ingest", allowedSources: new Set(["drive", "email", "chat"]), + } as any; + assert.deepEqual(decideSource(secret, { source: "drive", sourceRef: "drive:" }), + { ok: false, reason: "invalid-sourceRef" }); + assert.deepEqual(decideSource(secret, { source: "drive", sourceRef: `drive:${"a".repeat(600)}` }), + { ok: false, reason: "sourceRef-too-long" }); +}); + +// ── A secret owns SOURCES, not rows (Phase 2 gate, B) ───────────────────── + +test("finalize scopes a secret caller to the sources its key owns", () => { + const finalize = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + // The row's OWN source is selected and checked against the same list that + // scopes creation — otherwise the Apps Script key is authority over a + // mobile capture that belongs to a person. + assert.match(finalize, /source: true,/, "the source is selected"); + assert.match(finalize, /auth\.via === "secret" && !auth\.allowedSources\.has\(row\.source\)/); + assert.match(finalize, /error: "source-not-owned"/); + assert.match(finalize, /status: 403/); + // BEFORE any detail is returned or any late field applied. + const gate = finalize.indexOf("source-not-owned"); + for (const later of ["const maySee", "authorizeFinalization(auth, row.projectId", "await sealAndPublish("]) { + assert.ok(gate < finalize.indexOf(later), `the source gate precedes ${later}`); + } +}); + +test("the ingest key's source list is exactly the machine sources", async () => { + const { INGEST_ALLOWED_SOURCES } = await loadAuth(); + const { MACHINE_SOURCES } = await import("../src/lib/receipt-intake/intake-core"); + assert.deepEqual([...INGEST_ALLOWED_SOURCES].sort(), [...MACHINE_SOURCES].sort()); + // So a mobile or web row is owned by NO secret, which is the point. + for (const source of ["mobile", "web"]) { + assert.ok(!INGEST_ALLOWED_SOURCES.has(source), source); + } +}); + +// ── ONE provenance decision, not two (round-15 item 1) ──────────────────── + +test("the inline endpoint calls decideSource itself — it does not re-implement it", () => { + // The single-shot route carried a hand-written twin of decideSource, and it + // had drifted in two ways that mattered: it checked the global machine-source + // set instead of the sources THIS key owns, and it validated only the + // namespace prefix — so `drive:` with an empty tail was accepted as a + // permanent, unique idempotency key that every later empty-tail forward then + // collided with. A forwarder must not be able to tell the two doors apart. + const inline = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/route.ts"), + "utf8", + ); + assert.match(inline, /import \{[\s\S]*?decideSource,[\s\S]*?\} from "@\/lib\/receipt-intake\/intake-core";/); + assert.match(inline, /const decided = decideSource\(auth, \{/); + assert.match(inline, /if \(!decided\.ok\) return bad\(decided\.reason\);/); + + // And the copies are gone: no local sets, no hand-rolled namespace check, + // no second UUID pattern. + assert.ok(!/const MACHINE_SOURCES = new Set/.test(inline), "no local source set"); + assert.ok(!/const USER_SOURCES = new Set/.test(inline), "no local user-source set"); + assert.ok(!/const UUID_PATTERN =/.test(inline), "no second uuid pattern"); + assert.ok( + !/sourceRef\.startsWith\(`\$\{parsed\.source\}:`\)/.test(inline), + "no hand-rolled namespace check", + ); + + // /start reaches the same function. + const start = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/start/route.ts"), + "utf8", + ); + assert.match(start, /decideSource\(auth, \{/); +}); + +test("both doors reject the same refs, through the same decision", async () => { + // The unit half of the e2e that drives real requests: every rejection the + // inline endpoint can now produce comes from decideSource, so this pins the + // reasons the routes will return. + const { decideSource } = await import("../src/lib/receipt-intake/intake-core"); + const secret = { + ok: true, via: "secret", user: null, userVia: null, + capability: "ingest", allowedSources: new Set(["drive", "email", "chat"]), + } as any; + const cases: Array<[string, string]> = [ + ["drive:", "invalid-sourceRef"], + ["drive:short", "invalid-sourceRef"], + [`drive:${"a".repeat(600)}`, "sourceRef-too-long"], + ["chat:spaces/AAA/messages/x", "sourceRef-namespace-mismatch"], + ]; + for (const [sourceRef, reason] of cases) { + assert.deepEqual( + decideSource(secret, { source: "drive", sourceRef }), + { ok: false, reason }, + sourceRef, + ); + } + // A key that does not own the source is refused before any shape check. + const chatOnly = { ...secret, allowedSources: new Set(["chat"]) }; + assert.deepEqual( + decideSource(chatOnly, { source: "drive", sourceRef: "drive:1AbCdEfGhIjKlMnOp_qR" }), + { ok: false, reason: "invalid-source" }, + ); +}); diff --git a/tests/receipt-intake-book.test.ts b/tests/receipt-intake-book.test.ts new file mode 100644 index 000000000..a4d3be266 --- /dev/null +++ b/tests/receipt-intake-book.test.ts @@ -0,0 +1,1876 @@ +/** + * Booking, driven entirely through injected functions — no QuickBooks, no + * Supabase, no database, and no module mocking (CI is Node 20, where + * `mock.module` corrupts the require chain). + * + * This is a REAL BOOKS path, so the assertions are about money and about + * attempts: which failures cost the row a strike and which do not is the + * difference between a document a human sees today and one that quietly + * retries for a week. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + appliedTaxCents, + attachmentBlocker, + isTerminalAttachmentFailure, + bookReceipt, + buildGroups, + driveFileIdOf, + expenseAmountCents, + MIN_BOOKING_BUDGET_MS, + reconcileExistingExpense, + type BookableRow, + type BookDependencies, +} from "../src/lib/receipt-intake/book"; +// The ONE vendor comparison — the same function the identity check uses. +import { normalizeVendorName } from "../src/lib/qbo-receipt-push"; +import { + phaseConfidenceMin, + phaseSuggestionIsConfident, + RECEIPT_PHASE_CONFIDENCE_MIN, +} from "../src/lib/receipt-intake/intake-core"; +import { QBO_PURCHASE_MISMATCH_PREFIX } from "../src/lib/receipt-intake/book"; +import { finalizeDisposition, RECOVERABLE_PARK_REASONS } from "../src/lib/receipt-intake/stored-object"; +import { startOfDateInTimeZone } from "../src/lib/tz-date"; +import { QBTimeoutError } from "../src/lib/quickbooks"; +import { + QboAccountConfigError, + QboPurchaseFaultError, + QboVendorDuplicateError, +} from "../src/lib/qbo-receipt-push"; + +/** + * A createPurchase stub that stands for a call which REACHED the create. + * + * createQBReceiptPurchase fires `onBeforeCreate` immediately before the HTTP + * create and nowhere else, so whether a stub invokes it is the whole difference + * between "QuickBooks may hold a Purchase" and "nothing was ever sent" — which + * is what decides whether a parked row keeps its strong dedup key. Stubs + * standing for a PRE-create refusal (an account or vendor ensure, an ok:false + * decision) deliberately do not use this. + */ +function atCreate(fn: (...args: any[]) => Promise) { + return async (tokens: any, input: any, deadline: any, onBeforeCreate?: () => Promise) => { + await onBeforeCreate?.(); + return fn(tokens, input, deadline); + }; +} + +/** + * A stub for the ALREADY-EXISTS branch, mirroring the real control flow. + * + * createQBReceiptPurchase returns there from the idempotency query and never + * reaches qbCreateFn, so `onBeforeCreate` does NOT fire — only + * `onExistingPurchase` does. A fake that called onBeforeCreate anyway would + * mark the row "sent" and hide the very bug this branch had: a Purchase that + * exists while the row believes nothing was ever sent. + */ +function atExisting(fn: (...args: any[]) => Promise) { + return async ( + tokens: any, + input: any, + deadline: any, + _onBeforeCreate?: () => Promise, + onExistingPurchase?: () => Promise, + ) => { + await onExistingPurchase?.(); + return fn(tokens, input, deadline); + }; +} + +/** + * "QuickBooks holds this Purchase and it says what this document says." + * + * Every already-exists stub carries one, because the real + * createQBReceiptPurchase always does — and the difference between `match` and + * anything else decides whether an Expense is written from the read at all. + */ +const BOOKS_AGREE = { + verdict: "match" as const, + differences: [] as string[], + booked: { + totalAmount: 364.98, + txnDate: "2026-08-03", + vendor: "Lowes", + projectNames: ["Berg ADU"], + taxAmount: 29.2, + }, +}; + +/** The same shape, disagreeing however a test needs it to. */ +const booksSay = ( + verdict: "derive" | "review", + differences: string[], + booked: Partial = {}, +) => ({ verdict, differences, booked: { ...BOOKS_AGREE.booked, ...booked } }); + +const NOW = new Date("2026-09-01T12:00:00.000Z"); + +function row(overrides: Partial = {}): BookableRow { + return { + id: "intake-1", + source: "drive", + sourceRef: "drive:FILE123", + dryRun: false, + projectId: "proj-1", + costCodeId: null, + suggestedCostCodeId: "cc-plumb", + suggestedConfidence: 0.82, + storagePath: "receipts/intake/intake-1.jpg", + fileName: "receipt.jpg", + mimeType: "image/jpeg", + vendor: "Lowes", + txnDate: new Date("2026-08-03T00:00:00.000Z"), + totalCents: 36498, + taxCents: 2920, + docType: "receipt", + refNumber: "82766", + memo: null, + attempts: 0, + lastError: null, + sendAttempted: false, + fileSha256: "s".repeat(64), + claimToken: "claim-1", + stateReason: null, + ...overrides, + }; +} + +interface Recorder { + deps: BookDependencies; + sendMarks: string[]; + purchaseCalls: any[]; + expenses: any[]; + expenseUpdates: any[]; + intakeUpdates: any[]; + events: any[]; + /** Every qbPurchaseId the shared advisory lock was taken on. */ + locks: string[]; +} + +function recorder( + overrides: Partial = {}, + opts: { estimates?: { id: string }[]; existingExpense?: Record | null } = {}, +): Recorder { + const purchaseCalls: any[] = []; + const sendMarks: string[] = []; + const expenses: any[] = []; + const expenseUpdates: any[] = []; + const intakeUpdates: any[] = []; + const events: any[] = []; + const locks: string[] = []; + + const tx = { + project: { + findUnique: async () => ({ + id: "proj-1", + name: "Berg ADU", + estimates: opts.estimates ?? [{ id: "est-1" }], + }), + }, + expense: { + findUnique: async () => (opts.existingExpense ?? null), + create: async (args: any) => { expenses.push(args.data); return { id: `exp-${expenses.length}` }; }, + update: async (args: any) => { expenseUpdates.push(args.data); return {}; }, + }, + receiptIntake: { + update: async (args: any) => { intakeUpdates.push(args.data); return {}; }, + updateMany: async (args: any) => { intakeUpdates.push(args.data); return { count: 1 }; }, + }, + // The shared per-qbPurchaseId advisory lock. Recorded rather than + // ignored: taking it is the whole point of the fix, so a version that + // stopped taking it must fail a test. + $queryRawUnsafe: async (_sql: string, ...values: unknown[]) => { + locks.push(String(values[0])); + return []; + }, + $transaction: async (fn: any) => fn(tx), + }; + + const deps: BookDependencies = { + db: tx as any, + isPushEnabled: () => true, + isPushPaused: async () => false, + isDryRunEnabled: () => false, + getTokens: async () => ({ accessToken: "t", realmId: "r" }) as any, + createPurchase: atCreate(async (_tokens: any, input: any) => { + purchaseCalls.push(input); + return { ok: true, qbPurchaseId: "QB-1", docNumber: input.fileId.slice(0, 21), alreadyExists: false, attachment: "attached" }; + }) as any, + downloadBytes: async () => ({ ok: true as const, bytes: Buffer.from("bytes") }), + logEvent: async (event) => { events.push(event); }, + now: () => NOW, + companyTimeZone: async () => "America/Los_Angeles", + isCostCodeAllowed: async () => true, + markSendAttempted: async id => { sendMarks.push(id); return true; }, + ...overrides, + }; + return { deps, sendMarks, purchaseCalls, expenses, expenseUpdates, intakeUpdates, events, locks }; +} + +test("a taxed receipt splits into a pre-tax line and a sales-tax line that reconstruct the total", () => { + const groups = buildGroups("receipt", 36498, 2920, "82766"); + assert.deepEqual(groups, [ + { category: "Receipt (pre-tax)", amount: 335.78, lines: [] }, + { category: "Sales tax", amount: 29.2, tax: true, lines: [] }, + ]); + assert.equal(Math.round((groups[0].amount + groups[1].amount) * 100), 36498); +}); + +test("a check NEVER splits tax, however the tax field was read", () => { + // sendToQBOviaAPI.js:148 — the reseller-permit reclaim covers job materials, + // and a handwritten check is not a taxed vendor purchase. + const groups = buildGroups("check", 120000, 9000, "Check4178"); + assert.equal(groups.length, 1); + assert.equal(groups[0].category, "Check #4178"); + assert.equal(groups[0].tax, undefined); +}); + +test("a nonsense or absent tax falls back to the single-line shape", () => { + assert.equal(buildGroups("receipt", 10000, null, "1").length, 1); + assert.equal(buildGroups("receipt", 10000, 0, "1").length, 1); + assert.equal(buildGroups("receipt", 10000, 10000, "1").length, 1, "tax >= total is a bad read"); + assert.equal(buildGroups("receipt", 10000, 20000, "1").length, 1); +}); + +test("the Expense amount is the GROSS total, tax included, split or not", () => { + // Justin's call (2026-09-01), overriding the plan's pre-tax wording: the + // expenses already imported from QuickBooks record the gross line total, so + // booking pre-tax here would put two meanings of `amount` in one table and + // silently under-count every receipt this pipeline touched. + const split = buildGroups("receipt", 36498, 2920, "82766"); + assert.equal(split.length, 2, "the QBO Purchase still splits the tax"); + assert.equal(expenseAmountCents(split, 36498), 36498); + assert.equal(expenseAmountCents(buildGroups("receipt", 10000, null, "1"), 10000), 10000); +}); + +test("appliedTaxCents reports what POSTED, not what the model asked for", () => { + assert.equal(appliedTaxCents(buildGroups("receipt", 36498, 2920, "82766")), 2920); + // buildGroups rejects both of these, so the audit row must say 0 — the + // filing report reconciles against the Purchase, not against the read. + assert.equal(appliedTaxCents(buildGroups("check", 120000, 9000, "Check4178")), 0); + assert.equal(appliedTaxCents(buildGroups("receipt", 10000, 20000, "1")), 0); + assert.equal(appliedTaxCents(buildGroups("receipt", 10000, null, "1")), 0); +}); + +test("only a drive row books under the Drive file id", () => { + assert.equal(driveFileIdOf({ source: "drive", sourceRef: "drive:FILE123" }), "FILE123"); + assert.equal(driveFileIdOf({ source: "mobile", sourceRef: "mobile:abc" }), null); + assert.equal(driveFileIdOf({ source: "drive", sourceRef: "drive:" }), null); +}); + +test("a successful booking creates the Expense at the gross amount and marks the row BOOKED", async () => { + const r = recorder(); + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked"); + assert.equal(r.purchaseCalls.length, 1); + // DocNumber idempotency stays continuous with any v1 booking of the same file. + assert.equal(r.purchaseCalls[0].fileId, "FILE123"); + assert.equal(r.purchaseCalls[0].date, "2026-08-03"); + assert.equal(r.purchaseCalls[0].totalAmount, 364.98); + assert.equal(r.purchaseCalls[0].groups.length, 2); + + assert.equal(r.expenses.length, 1); + assert.equal(r.expenses[0].amount, 364.98, "gross, tax included"); + assert.equal(r.expenses[0].estimateId, "est-1"); + assert.equal(r.expenses[0].costCodeId, "cc-plumb", "falls back to the model's phase suggestion"); + assert.equal(r.expenses[0].qbPurchaseId, "QB-1"); + // QBO-managed from birth — status "Reviewed" keeps it out of the + // bookkeeper's actionable "Pending" queue (manager/receipts/page.tsx), + // matching every other qbPurchaseId-bearing Expense. approve/edit/delete + // reject anything with a qbPurchaseId (qbo-expense-guard.ts), so leaving + // it "Pending" would strand it in a queue nothing can act on. + assert.equal(r.expenses[0].status, "Reviewed"); + assert.equal(r.expenses[0].receiptUrl, "https://drive.google.com/file/d/FILE123/view"); + + assert.equal(r.intakeUpdates[0].state, "BOOKED"); + assert.equal(r.intakeUpdates[0].qbPurchaseId, "QB-1"); + assert.equal(r.events[0].kind, "receipt-push"); + assert.equal(r.events[0].source, "intake-worker"); + assert.equal(r.events[0].amountCents, 36498); + assert.equal(r.events[0].taxCents, 2920, "the tax that actually posted"); +}); + +test("a tax-implausible warning survives into BOOKED", async () => { + // Codex gate: READ->BOOKING used to clear stateReason unconditionally, and + // BOOKED cleared it again — so an automatically booked receipt with a bad + // tax read became indistinguishable from one with no tax read at all. + const r = recorder(); + const result = await bookReceipt(row({ stateReason: "tax-implausible" }), r.deps); + + assert.equal(result.outcome, "booked"); + assert.equal(r.intakeUpdates[0].state, "BOOKED"); + assert.equal(r.intakeUpdates[0].stateReason, "tax-implausible"); +}); + +test("a transient BOOKING reason (e.g. a past defer) does NOT survive into BOOKED", async () => { + const r = recorder(); + const result = await bookReceipt(row({ stateReason: "push-paused" }), r.deps); + + assert.equal(result.outcome, "booked"); + assert.equal(r.intakeUpdates[0].stateReason, null, "only the tax warning is meant to survive"); +}); + +test("an explicitly chosen cost code beats the model's suggestion", async () => { + const r = recorder(); + await bookReceipt(row({ costCodeId: "cc-chosen" }), r.deps); + assert.equal(r.expenses[0].costCodeId, "cc-chosen"); +}); + +test("a non-drive row books under its intake id and stores a resolvable reference", async () => { + const r = recorder(); + await bookReceipt(row({ source: "mobile", sourceRef: "mobile:abc", id: "intake-9" }), r.deps); + // The QBO identity still needs SOMETHING unique, and the intake id is it. + assert.equal(r.purchaseCalls[0].fileId, "intake-9"); + // The Expense holds a stable reference — not a signed URL that expires, and + // not a bare path that says nothing about which bucket it is in. + assert.equal(r.expenses[0].receiptUrl, "receipt-intake://receipt-intake/receipts/intake/intake-1.jpg"); +}); + +test("the audit event calls a DRIVE id fileId, and everything else intakeId", async () => { + // `fileId` is dual-written into the typed `driveFileId` column, which the + // cutover queries to decide whether v1 already booked a document. An intake + // cuid there fills it with ids no Drive query can ever match. + const mobile = recorder(); + await bookReceipt(row({ source: "mobile", sourceRef: "mobile:abc", id: "intake-9" }), mobile.deps); + const mobileDetail = mobile.events[0].detail as Record; + assert.ok(!("fileId" in mobileDetail), "no Drive file exists for this row"); + assert.equal(mobileDetail.intakeId, "intake-9"); + + const drive = recorder(); + await bookReceipt(row({ source: "drive", sourceRef: "drive:FILE9", id: "intake-9" }), drive.deps); + const driveDetail = drive.events[0].detail as Record; + assert.equal(driveDetail.fileId, "FILE9", "a real Drive id, not the intake row id"); + assert.equal(driveDetail.intakeId, "intake-9", "and the row id is still carried"); +}); + +test("a project with no estimate is terminal, spends NO attempt, and RELEASES the strong key", async () => { + // Nothing was ever sent, so the row is holding a dedup key on behalf of a + // document that never became a purchase. A corrected re-send of the same + // receipt would be quarantined against it (v3.5 rule). + const r = recorder({}, { estimates: [] }); + const result = await bookReceipt(row(), r.deps); + assert.deepEqual(result, { outcome: "needs-review", reason: "no-estimate", releaseStrongKey: true }); + assert.equal(r.purchaseCalls.length, 0, "QuickBooks is never touched"); + assert.equal(r.expenses.length, 0); +}); + +test("every PRE-send refusal releases the key; every POST-send one holds it", async () => { + // Pre-send: nothing exists in QuickBooks, so the key must go back. + for (const [rowOverrides, reason] of [ + [{ projectId: null }, "no-estimate"], + [{ totalCents: 0 }, "refund-or-zero"], + [{ totalCents: -2257 }, "refund-or-zero"], + [{ txnDate: null }, "invalid-date"], + ] as const) { + const r = recorder(); + const result = await bookReceipt(row(rowOverrides), r.deps); + assert.deepEqual(result, { outcome: "needs-review", reason, releaseStrongKey: true }, reason); + assert.equal(r.purchaseCalls.length, 0, reason); + } + + // Post-send: QBO may hold a Purchase whose response we lost, so the key + // stays claimed even though the row is parked. + const faulted = recorder({ + createPurchase: atCreate(async () => { throw new QboPurchaseFaultError(400, "closed period", "6210"); }) as any, + }); + const result = await bookReceipt(row(), faulted.deps); + assert.equal((result as any).releaseStrongKey, false); +}); + +test("round-31 P0: row.sendAttempted from an EARLIER attempt survives into every needs-review path", async () => { + // The sequence the finding described: attempt 1 reaches QBO and commits a + // Purchase, but the response or the Expense tx fails, so the row parks + // with row.sendAttempted persisted true. Attempt 2 re-reads the row and + // hits a refusal that, taken on its OWN, never touched QuickBooks this + // time — a deleted estimate, a missing object, an ok:false decision. None + // of those three shapes may release the strong key: QBO may already hold + // a Purchase from attempt 1, and releasing it lets a resubmission mint a + // second one. + + // 1. parkedBeforeSend — pre-send refusals reached WITHOUT this attempt + // ever calling QBO, on a row that already sent once. + for (const [rowOverrides, reason] of [ + [{ projectId: null }, "no-estimate"], + [{ totalCents: 0 }, "refund-or-zero"], + [{ txnDate: null }, "invalid-date"], + ] as const) { + const r = recorder(); + const result = await bookReceipt(row({ ...rowOverrides, sendAttempted: true }), r.deps); + assert.deepEqual( + result, + { outcome: "needs-review", reason, releaseStrongKey: false }, + `${reason}: an earlier attempt may already hold a Purchase`, + ); + assert.equal(r.purchaseCalls.length, 0, reason); + } + + // The exact scenario named in the finding: the project's estimate was + // deleted between attempt 1 and attempt 2. + const deletedEstimate = recorder({}, { estimates: [] }); + assert.deepEqual( + await bookReceipt(row({ sendAttempted: true }), deletedEstimate.deps), + { outcome: "needs-review", reason: "no-estimate", releaseStrongKey: false }, + ); + + // Also named in the finding: the receipt object has since gone missing. + const missingObject = recorder({ downloadBytes: async () => ({ ok: false, kind: "missing" }) }); + assert.deepEqual( + await bookReceipt(row({ sendAttempted: true }), missingObject.deps), + { outcome: "needs-review", reason: "receipt-bytes-missing", releaseStrongKey: false }, + ); + + // 2. The terminal catch — THIS attempt throws before ever reaching the + // create hook (sent.attempted stays false), but the row already sent. + const preCreateFault = recorder({ createPurchase: async () => { throw new QboVendorDuplicateError("Lowes"); } }); + assert.deepEqual( + await bookReceipt(row({ sendAttempted: true }), preCreateFault.deps), + { outcome: "needs-review", reason: "qbo-fault:vendor-duplicate", releaseStrongKey: false }, + ); + + // 3. ok:false — a deterministic refusal decided before qbCreateFn runs on + // THIS attempt, but the row already sent on an earlier one. + const okFalse = recorder({ createPurchase: async () => ({ ok: false, reason: "missing-vendor" }) as any }); + assert.deepEqual( + await bookReceipt(row({ sendAttempted: true }), okFalse.deps), + { outcome: "needs-review", reason: "qbo-fault:missing-vendor", releaseStrongKey: false }, + ); +}); + +test("the push kill switch and the pause switch defer without spending an attempt", async () => { + const disabled = recorder({ isPushEnabled: () => false }); + assert.deepEqual(await bookReceipt(row(), disabled.deps), { outcome: "deferred", reason: "push-disabled" }); + assert.equal(disabled.purchaseCalls.length, 0); + + const paused = recorder({ isPushPaused: async () => true }); + assert.deepEqual(await bookReceipt(row(), paused.deps), { outcome: "deferred", reason: "push-paused" }); + assert.equal(paused.purchaseCalls.length, 0); +}); + +test("a dryRun row can never reach QuickBooks, even called directly", async () => { + // The worker already refuses to route a dry-run row here. This second guard + // exists because "no QBO calls in shadow mode" is the safety promise of the + // whole phase, and one guard in one caller is not a promise. + const r = recorder(); + const result = await bookReceipt(row({ dryRun: true }), r.deps); + assert.equal(result.outcome, "deferred"); + assert.equal(r.purchaseCalls.length, 0); + assert.equal(r.expenses.length, 0); +}); + +test("the global kill switch stops a row even when its persisted flag says live", async () => { + // A row's dryRun flag is snapshotted once at intake, so it is not itself a + // kill switch: reverting RECEIPT_INTAKE_DRYRUN after rows were already + // claimed dryRun=false must still stop them from reaching QuickBooks. + const r = recorder({ isDryRunEnabled: () => true }); + const result = await bookReceipt(row({ dryRun: false }), r.deps); + assert.deepEqual(result, { outcome: "deferred", reason: "push-disabled" }); + assert.equal(r.purchaseCalls.length, 0); + assert.equal(r.expenses.length, 0); +}); + +test("QBO business-rule faults are TERMINAL, never retried", async () => { + // A fault raised by the PURCHASE create may have created one: QuickBooks + // answered, and a lost response looks exactly like this. Key retained. + const posted = recorder({ + createPurchase: atCreate(async () => { + throw new QboPurchaseFaultError(400, "closed period", "6210"); + }) as any, + }); + assert.deepEqual(await bookReceipt(row(), posted.deps), { + outcome: "needs-review", reason: "qbo-fault:6210", releaseStrongKey: false, + }); + + // The ENSURES — resolving the expense account, creating the vendor — run + // BEFORE the create, so nothing was posted and the strong key goes back. + // This is what moving the fenced send mark to the create bought: these two + // used to quarantine the corrected re-submission against a booking that + // never happened. + const preCreate: [unknown, string][] = [ + [new QboAccountConfigError("bad account"), "qbo-fault:account-config"], + [new QboVendorDuplicateError("Lowes"), "qbo-fault:vendor-duplicate"], + ]; + for (const [error, reason] of preCreate) { + const r = recorder({ createPurchase: async () => { throw error; } }); + const result = await bookReceipt(row(), r.deps); + assert.deepEqual(result, { outcome: "needs-review", reason, releaseStrongKey: true }, reason); + assert.deepEqual(r.sendMarks, [], "the create was never reached"); + assert.equal(r.expenses.length, 0); + } +}); + +test("EVERY ok:false happens before the create, so all of them RELEASE the key", async () => { + // The list is exhaustive on purpose: project-not-matched, missing-vendor, + // invalid-date, amount-mismatch, duplicate-name and the overhead cases are + // all decided before qbCreateFn runs, and docnumber-conflict is the + // idempotency QUERY finding somebody ELSE'S Purchase. So no Purchase exists + // for this row, and holding the strong key would quarantine the corrected + // re-submission against a booking that never happened. + const reasons = [ + "docnumber-conflict", "project-not-matched", "missing-vendor", "invalid-date", + "amount-mismatch", "duplicate-name", "invalid-group-amount", + "overhead-account-not-matched", "overhead-tax-unsupported", + ]; + for (const reason of reasons) { + const r = recorder({ createPurchase: async () => ({ ok: false, reason }) as any }); + assert.deepEqual(await bookReceipt(row(), r.deps), { + outcome: "needs-review", + reason: `qbo-fault:${reason}`, + releaseStrongKey: true, + }, reason); + assert.equal(r.expenses.length, 0, reason); + } +}); + +// ── Never a Purchase without its receipt ON it (round-3 gate, item 4) ─────── + +test("a format QBO cannot attach is refused BEFORE the Purchase is created", async () => { + // The QBO core returns ok:true with attachment:"skipped" for these, and the + // old code marked that BOOKED — a Purchase in the real books with no + // receipt, which a bookkeeper cannot spot because it looks complete. Every + // accepted .txt receipt hit this. + const r = recorder(); + const result = await bookReceipt(row({ mimeType: "text/plain" }), r.deps); + assert.equal(result.outcome, "needs-review"); + assert.match((result as any).reason, /^unsupported-attachment:mime:text\/plain/); + assert.equal((result as any).releaseStrongKey, true, "nothing was sent"); + assert.equal(r.purchaseCalls.length, 0, "no Purchase is created"); +}); + +test("a file over QBO's 8MB attachment ceiling is refused before the create", async () => { + // Our intake ceiling is 15MB and QBO's attachment ceiling is 8MB, so this + // gap is reachable by a real phone photo. + const big = Buffer.alloc(9 * 1024 * 1024, 1); + const r = recorder({ downloadBytes: async () => ({ ok: true, bytes: big }) }); + const result = await bookReceipt(row(), r.deps); + assert.match((result as any).reason, /^unsupported-attachment:size:/); + assert.equal(r.purchaseCalls.length, 0); +}); + +test("attachmentBlocker mirrors QBO's own ceilings", () => { + assert.equal(attachmentBlocker("image/jpeg", 1000), null); + assert.equal(attachmentBlocker("application/pdf", 1000), null); + assert.equal(attachmentBlocker("image/heic", 1000), null); + assert.equal(attachmentBlocker("image/jpeg; charset=binary", 1000), null, "parameters are stripped"); + assert.match(attachmentBlocker("text/plain", 1000)!, /^mime:/); + assert.match(attachmentBlocker("image/tiff", 1000)!, /^mime:/); + // Exactly 8MB is allowed; one byte more is not. + assert.equal(attachmentBlocker("image/jpeg", 8 * 1024 * 1024), null); + assert.match(attachmentBlocker("image/jpeg", 8 * 1024 * 1024 + 1)!, /^size:/); +}); + +test("an attachment upload that FAILED is retried, never reported as booked", async () => { + const r = recorder({ + createPurchase: atCreate(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment: "failed:500", + })) as any, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "retry"); + assert.match((result as any).reason, /^attachment-failed:failed:500/); + assert.equal(r.expenses.length, 0, "no Expense until the receipt is actually on the Purchase"); +}); + +test("an EXISTING purchase is held to the SAME attachment standard", async () => { + // This is the path that matters: it is reached by every retry after a lost + // response — exactly when a Purchase is most likely to be sitting in the + // books without its image. It was the one path exempt from the check. + const failing = recorder({ + createPurchase: atExisting(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "failed:500", existing: BOOKS_AGREE, + })) as any, + }); + const failed = await bookReceipt(row(), failing.deps); + assert.equal(failed.outcome, "retry", "an upload fault on an existing Purchase is recoverable"); + assert.equal(failing.expenses.length, 0, "and it is NOT booked meanwhile"); + + const skipped = recorder({ + createPurchase: atExisting(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "skipped", existing: BOOKS_AGREE, + })) as any, + }); + const skippedResult = await bookReceipt(row(), skipped.deps); + assert.equal(skippedResult.outcome, "needs-review"); + assert.equal((skippedResult as any).reason, "unsupported-attachment:skipped"); + assert.equal(skipped.expenses.length, 0); +}); + +test("a previous attachment failure does NOT block the recovery attempt", async () => { + // The QBO core re-checks and re-uploads the file for an existing Purchase + // (ensureAttachmentOnExistingPurchase), so the retry IS the recovery. + // Short-circuiting on lastError made the stranded-receipt case permanent — + // the opposite of what the guard was for. + const r = recorder({ + createPurchase: atExisting(async (_t: any, input: any) => { + r.purchaseCalls.push(input); + return { ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "already-attached", existing: BOOKS_AGREE } as any; + }) as any, + }); + const result = await bookReceipt(row({ lastError: "attachment-failed:failed:500" }), r.deps); + assert.equal(result.outcome, "booked", "the recovery succeeded and the row books"); + assert.equal(r.purchaseCalls.length, 1, "the recovery attempt actually happened"); + assert.equal(r.expenses.length, 1); +}); + +test("already-attached counts as attached on the fresh-create path too", async () => { + const r = recorder({ + createPurchase: atCreate(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment: "already-attached", + })) as any, + }); + assert.equal((await bookReceipt(row(), r.deps)).outcome, "booked"); +}); + +test("a QBTimeoutError retries on the backoff schedule", async () => { + const r = recorder({ createPurchase: atCreate(async () => { throw new QBTimeoutError("timed out"); }) as any }); + const first = await bookReceipt(row({ attempts: 0 }), r.deps); + assert.equal(first.outcome, "retry"); + assert.equal((first as any).attempts, 1); + assert.equal((first as any).nextRetryAt.getTime(), NOW.getTime() + 5 * 60_000); + assert.equal((first as any).reason, "QBTimeoutError"); + + const third = await bookReceipt(row({ attempts: 2 }), recorder({ + createPurchase: atCreate(async () => { throw new QBTimeoutError("timed out"); }) as any, + }).deps); + assert.equal((third as any).nextRetryAt.getTime(), NOW.getTime() + 60 * 60_000); +}); + +test("a plain network error retries; MAX_BOOK_ATTEMPTS means 20 attempts in TOTAL", async () => { + const transient = recorder({ createPurchase: atCreate(async () => { throw new TypeError("fetch failed"); }) as any }); + assert.equal((await bookReceipt(row({ attempts: 5 }), transient.deps)).outcome, "retry"); + + // row.attempts 18 -> this is attempt 19: still retryable. + const nearly = recorder({ createPurchase: atCreate(async () => { throw new TypeError("fetch failed"); }) as any }); + assert.equal((await bookReceipt(row({ attempts: 18 }), nearly.deps)).outcome, "retry"); + + // row.attempts 19 -> this is attempt 20, the last one the constant allows. + // sendAttempted is what decides the key, not the fact of reaching the limit. + const exhausted = recorder({ createPurchase: atCreate(async () => { throw new TypeError("fetch failed"); }) as any }); + assert.deepEqual(await bookReceipt(row({ attempts: 19, sendAttempted: true }), exhausted.deps), { + outcome: "needs-review", + reason: "max-retries", + // A send HAS been attempted, so QBO may hold a Purchase: keep the key. + releaseStrongKey: false, + }); + + // ...and a row that burned all 20 attempts WITHOUT ever reaching QuickBooks + // (storage faults, say) created no Purchase, so its key must go back. + const neverSent = recorder({ + downloadBytes: async () => ({ ok: false, kind: "transient", message: "ECONNRESET" }), + }); + assert.deepEqual(await bookReceipt(row({ attempts: 19, sendAttempted: false }), neverSent.deps), { + outcome: "needs-review", + reason: "max-retries", + releaseStrongKey: true, + }); +}); + +test("alreadyExists books identically — the lost-response retry", async () => { + const r = recorder({ + createPurchase: atExisting(async (_t: any, input: any) => ({ + ok: true, qbPurchaseId: "QB-7", docNumber: input.fileId.slice(0, 21), + alreadyExists: true, attachment: "already-attached", existing: BOOKS_AGREE, + })) as any, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal((result as any).alreadyExisted, true); + assert.equal(r.expenses.length, 1); + assert.equal(r.events[0].status, "already-exists"); +}); + + +// ── Never link an Expense blindly (Codex round-12 item 2) ────────────────── +// +// The row under this `qbPurchaseId` is not necessarily one we wrote. The +// expected race: the worker creates the QBO Purchase, dies before its commit, +// and QBO expense sync imports that Purchase before the retry comes round. The +// imported row is right about the money and knows nothing about this receipt — +// `QboExpenseWrite` carries neither `costCodeId` nor `receiptUrl`. Legacy and +// human-edited rows can disagree about more than that. +// +// Selecting `{ id: true }` and taking `existing ?? create` marked the intake +// row BOOKED against whatever was there. + +/** + * What the QBO importer writes for this Purchase: right about the money, + * silent about the receipt. NOTE the date anchor — qboTransactionDate() writes + * `${txnDate}T00:00:00.000Z`, UTC midnight, while this file writes the + * company's LOCAL midnight for the same calendar day. The two differ by hours + * and mean the same day; a reconcile that compared instants would call every + * imported row a conflict. + */ +function importedExpense(over: Record = {}) { + return { + id: "exp-existing", + estimateId: "est-1", + amount: 364.98, + vendor: "Lowes", + date: new Date("2026-08-03T00:00:00.000Z"), + costCodeId: null, + receiptUrl: null, + ...over, + }; +} + +/** The row a crash-gap retry finds of its OWN making: fully attributed. */ +function matchingExpense(over: Record = {}) { + return importedExpense({ + // Local midnight Pacific for the same day — the other anchor. + date: new Date("2026-08-03T07:00:00.000Z"), + costCodeId: "cc-plumb", + receiptUrl: "https://drive.google.com/file/d/FILE123/view", + ...over, + }); +} + +test("an existing Expense for the same Purchase is reused, never duplicated", async () => { + // The crash-gap retry finding its OWN row: everything agrees, so it links. + const r = recorder({}, { existingExpense: matchingExpense() }); + const result = await bookReceipt(row(), r.deps); + assert.equal((result as any).expenseId, "exp-existing"); + assert.equal(r.expenses.length, 0, "no second Expense row"); + assert.deepEqual(r.locks, ["QB-1"], "and the shared per-Purchase lock was taken first"); +}); + +test("IMPORTER WINS: the receipt fills the attribution the sync could not know", async () => { + // The expected race. The importer's row carries no cost code and no + // receiptUrl — it cannot, those columns are not in QboExpenseWrite — so the + // retry fills them rather than linking a job-cost row that points at + // nothing and sits on no phase. + const r = recorder({}, { existingExpense: importedExpense() }); + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked"); + assert.equal((result as any).expenseId, "exp-existing"); + assert.equal(r.expenses.length, 0, "still no duplicate"); + assert.equal(r.expenseUpdates.length, 1, "the existing row was completed, not replaced"); + assert.equal(r.expenseUpdates[0].costCodeId, "cc-plumb"); + assert.match(String(r.expenseUpdates[0].receiptUrl), /FILE123/); + // Money and identity agreed, so nothing there was touched. + for (const field of ["estimateId", "amount", "vendor", "date"]) { + assert.ok(!(field in r.expenseUpdates[0]), `${field} is not rewritten`); + } +}); + +test("the two date ANCHORS are not a conflict — same day, different midnight", async () => { + // The trap this test exists for: the importer stamps UTC midnight and this + // file stamps local midnight, so `existing.date.getTime() !== receipt.date` + // for EVERY imported row. Comparing instants would park the whole queue. + const utcAnchored = reconcileExistingExpense(importedExpense() as never, receiptValues()); + const localAnchored = reconcileExistingExpense(matchingExpense() as never, receiptValues()); + assert.deepEqual(utcAnchored.conflicts, [], "UTC-midnight marker, from the importer"); + assert.deepEqual(localAnchored.conflicts, [], "local midnight, from this file"); + // CONTROL: a genuinely different DAY is still a conflict. + const wrongDay = reconcileExistingExpense( + importedExpense({ date: new Date("2026-08-04T00:00:00.000Z") }) as never, + receiptValues(), + ); + assert.deepEqual(wrongDay.conflicts, ["date"]); +}); + +test("a human's cost code is never overwritten by the receipt's suggestion", async () => { + // costCodeId and receiptUrl are fill-only. The importer cannot write either + // column, so a value there came from a person or an earlier receipt — and + // theirs is the answer that stands. There is no provenance column and no + // `notHumanCodedExpenseWhere` helper in this codebase; "the importer could + // not have written this" is the honest predicate. + const r = recorder({}, { existingExpense: importedExpense({ costCodeId: "cc-electrical" }) }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + const patch = r.expenseUpdates[0] ?? {}; + assert.ok(!("costCodeId" in patch), "the human's phase stands"); + assert.match(String(patch.receiptUrl), /FILE123/, "but the missing receipt link is still filled"); +}); + +test("a CONFLICTING amount parks for a human instead of linking", async () => { + // Two views of one Purchase that disagree about real money. Nothing here + // can pick a winner, and linking would mark the intake row BOOKED against + // a job-cost figure the books do not have. + const r = recorder({}, { existingExpense: importedExpense({ amount: 401.11 }) }); + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "needs-review"); + assert.match((result as any).reason, /^expense-conflict:/); + assert.match((result as any).reason, /amount/); + // THE KEY IS RETAINED. A Purchase provably exists in QuickBooks, so + // releasing it would let a resubmission of this receipt book a second one. + assert.equal((result as any).releaseStrongKey, false); + assert.equal(r.expenses.length, 0, "nothing written"); + assert.equal(r.expenseUpdates.length, 0); + assert.ok( + !r.intakeUpdates.some(u => u.state === "BOOKED"), + "and the intake row was never marked BOOKED", + ); +}); + +test("a CONFLICTING job parks too, and names the field", async () => { + const r = recorder({}, { existingExpense: importedExpense({ estimateId: "est-other" }) }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "needs-review"); + assert.match((result as any).reason, /estimate/); + assert.equal((result as any).releaseStrongKey, false); +}); + +test("reconcile: the truth table, field by field", () => { + const base = () => reconcileExistingExpense(importedExpense() as never, receiptValues()); + assert.deepEqual(base().conflicts, [], "the expected importer row is not a conflict"); + assert.deepEqual( + Object.keys(base().fill).sort(), + ["costCodeId", "receiptUrl"], + "only what the importer could not know", + ); + + // Nullable columns: absent means missing attribution, not disagreement. + const noVendor = reconcileExistingExpense(importedExpense({ vendor: null }) as never, receiptValues()); + assert.deepEqual(noVendor.conflicts, []); + assert.equal(noVendor.fill.vendor, "Lowes"); + const noDate = reconcileExistingExpense(importedExpense({ date: null }) as never, receiptValues()); + assert.deepEqual(noDate.conflicts, []); + assert.ok(noDate.fill.date instanceof Date); + + // Populated and different: a real contradiction. + assert.deepEqual( + reconcileExistingExpense(importedExpense({ vendor: "Home Depot" }) as never, receiptValues()).conflicts, + ["vendor"], + ); + // Several at once are all reported, so a reviewer sees the whole picture. + assert.deepEqual( + reconcileExistingExpense( + importedExpense({ estimateId: "est-9", amount: 1, vendor: "X" }) as never, + receiptValues(), + ).conflicts, + ["estimate", "amount", "vendor"], + ); + // A receipt with no phase to offer fills nothing rather than nulling one. + const noSuggestion = reconcileExistingExpense( + importedExpense() as never, + { ...receiptValues(), costCodeId: null }, + ); + assert.ok(!("costCodeId" in noSuggestion.fill)); +}); + +/** The receipt's canonical values, as bookReceipt computes them for row(). */ +function receiptValues() { + return { + estimateId: "est-1", + amountCents: 36498, + vendor: "Lowes", + date: new Date("2026-08-03T07:00:00.000Z"), + calendarDay: "2026-08-03", + timeZone: "America/Los_Angeles", + costCodeId: "cc-plumb", + receiptUrl: "https://drive.google.com/file/d/FILE123/view", + }; +} + +test("a DB failure AFTER the Purchase exists retries — the create is idempotent", async () => { + const r = recorder(); + (r.deps.db as any).$transaction = async () => { throw new Error("connection reset"); }; + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "retry"); +}); + +// ── Never a Purchase without its receipt (Codex round 3, item 3) ──────────── + +test("a MISSING receipt file refuses the booking outright", async () => { + // Booking with `fileBase64: undefined` produced a QBO Purchase with no + // attachment — the one failure the bookkeeper cannot fix later, because the + // Purchase looks complete and nothing flags it. The receipt IS the evidence + // for the expense. + const r = recorder({ downloadBytes: async () => ({ ok: false, kind: "missing" }) }); + const result = await bookReceipt(row(), r.deps); + assert.deepEqual(result, { + outcome: "needs-review", + reason: "receipt-bytes-missing", + // Pre-send: nothing reached QuickBooks, so the key goes back for a + // corrected re-upload. + releaseStrongKey: true, + }); + assert.equal(r.purchaseCalls.length, 0, "QuickBooks is never touched"); + assert.equal(r.expenses.length, 0); +}); + +test("a TRANSIENT storage fault retries instead of parking a good receipt", async () => { + const r = recorder({ + downloadBytes: async () => ({ ok: false, kind: "transient", message: "ECONNRESET" }), + }); + const result = await bookReceipt(row({ attempts: 1 }), r.deps); + assert.equal(result.outcome, "retry"); + assert.match((result as any).reason, /^storage:/); + assert.equal(r.purchaseCalls.length, 0); +}); + +test("the receipt bytes always ride along with the Purchase", async () => { + const r = recorder(); + await bookReceipt(row(), r.deps); + assert.equal(r.purchaseCalls[0].fileBase64, Buffer.from("bytes").toString("base64")); + assert.equal(r.purchaseCalls[0].fileContentType, "image/jpeg"); +}); + +// ── Expense.date is a business calendar day (round-6 item 3) ──────────────── + +test("Expense.date is re-anchored to the company's local midnight", async () => { + // txnDate is a @db.Date column and round-trips as UTC midnight. Written + // straight into Expense.date (a full timestamp) that records 5pm the + // PREVIOUS day in Pacific, and every job-cost or variance report bounded by + // local midnight then counts the expense in the wrong period. + const r = recorder(); + await bookReceipt(row({ txnDate: new Date("2026-08-03T00:00:00.000Z") }), r.deps); + + const written = r.expenses[0].date as Date; + assert.equal(written.toISOString(), "2026-08-03T07:00:00.000Z", "local midnight PDT"); + + // The assertion that matters: read back in the company zone it is the 3rd. + const localDay = new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", + }).format(written); + assert.equal(localDay, "2026-08-03"); + + // Control: the raw txnDate would have read as the 2nd. That was the bug. + assert.equal( + new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", + }).format(new Date("2026-08-03T00:00:00.000Z")), + "2026-08-02", + ); + + // ...and QBO still gets the bare calendar day, unchanged. + assert.equal(r.purchaseCalls[0].date, "2026-08-03"); +}); + +test("winter dates use the winter offset — no hardcoded -07:00", async () => { + const r = recorder(); + await bookReceipt(row({ txnDate: new Date("2026-01-15T00:00:00.000Z") }), r.deps); + assert.equal((r.expenses[0].date as Date).toISOString(), "2026-01-15T08:00:00.000Z"); +}); + +// ── A booking must not start without room to finish (round-6 item 4) ──────── + +test("a booking with less than 25s of runway DEFERS instead of starting", async () => { + // Two QuickBooks round trips plus the attachment upload and the commit do + // not fit in a few seconds, and a booking cut off mid-flight is the worst + // outcome available: the Purchase may exist in the real books while the row + // never learns it did. + // An absolute deadline that is already nearly spent. + const r = recorder({ deadline: { startedAt: Date.now() - 50_000, budgetMs: 55_000 } }); + const result = await bookReceipt(row(), r.deps); + assert.deepEqual(result, { outcome: "deferred", reason: "out-of-budget" }); + assert.equal(r.purchaseCalls.length, 0, "QuickBooks is never touched"); + assert.equal(r.expenses.length, 0); +}); + +test("the runway check spends no attempt — the document did nothing wrong", async () => { + const r = recorder({ deadline: { startedAt: Date.now() - 60_000, budgetMs: 55_000 } }); + const result = await bookReceipt(row({ attempts: 3 }), r.deps); + assert.equal(result.outcome, "deferred"); + assert.ok(!("attempts" in result), "not a retry, so no attempt is spent"); +}); + +test("ample runway books normally, and threads ONE deadline into both QBO calls", async () => { + const seen: unknown[] = []; + const deadline = { startedAt: Date.now(), budgetMs: 55_000 }; + const r = recorder({ + deadline, + getTokens: async d => { seen.push(d); return { accessToken: "t", realmId: "r" } as any; }, + createPurchase: atCreate(async (_t: any, input: any, d: any) => { + seen.push(d); + r.purchaseCalls.push(input); + return { ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment: "attached" } as any; + }) as any, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal(seen.length, 2); + assert.strictEqual(seen[0], seen[1], "the SAME deadline object, so a slow refresh shortens the create"); + assert.strictEqual(seen[0], deadline, "and it is the INVOCATION's deadline, not a fresh one"); +}); + +test("MIN_BOOKING_BUDGET_MS is the documented 25s", () => { + assert.equal(MIN_BOOKING_BUDGET_MS, 25_000); +}); + +// ── The phase is re-validated against the FINAL project (Phase 3 gate, a) ─── + +test("a cost code that is not a phase of THIS job is cleared, and the row still books", async () => { + // The scenario: the receipt was READ while it had no project (NEEDS_JOB) or + // a different one, and a human then assigned the real job. A cost code from + // the old project is not a phase of the new one, and posting against it puts + // real money on a phase that job does not have — which every variance report + // reads as overspend on a line nobody budgeted. + const r = recorder({ isCostCodeAllowed: async () => false }); + const result = await bookReceipt(row({ costCodeId: "cc-from-another-job" }), r.deps); + + assert.equal(result.outcome, "booked", "the receipt is fine — it books UNCODED"); + assert.equal(r.expenses[0].costCodeId, null, "the wrong phase is cleared, never posted"); + assert.match(r.expenses[0].description, /phase cleared \(not a phase of this job\)/); + assert.equal(r.events[0].detail.phaseRejected, "cc-from-another-job", "and it is auditable"); +}); + +test("the SUGGESTED code is checked against the final project too", async () => { + // The model suggested it from the phase list of whatever project the row had + // at READ time. That list is not authority over the project it books to. + const asked: Array<[string, string]> = []; + const r = recorder({ + isCostCodeAllowed: async (projectId, costCodeId) => { + asked.push([projectId, costCodeId]); + return false; + }, + }); + await bookReceipt(row({ costCodeId: null }), r.deps); + // TWICE: once immediately before the QBO create, once immediately before + // the Expense write. The create is a network round trip, and a project + // reassignment that lands in between must not reach job cost. + assert.deepEqual(asked, [["proj-1", "cc-plumb"], ["proj-1", "cc-plumb"]]); + assert.equal(r.expenses[0].costCodeId, null); +}); + +test("the phase is re-checked AFTER the create, not just before it", async () => { + // The window that matters is the one around the money write. This proves + // the second check is real: the same row, answered differently the second + // time, must produce the LATER answer. + let call = 0; + const r = recorder({ + isCostCodeAllowed: async () => { + call++; + return call === 1; // allowed before the send, revoked after it + }, + }); + await bookReceipt(row({ costCodeId: "cc-demo" }), r.deps); + assert.equal(call, 2, "asked on both sides of the create"); + assert.equal(r.expenses[0].costCodeId, null, "the post-create answer wins"); + assert.match(r.expenses[0].description, /phase cleared/); +}); + +test("unassigned during READ, assigned before BOOKING: the phase is re-checked", async () => { + // End to end for the exact sequence the gate named. + const allowedByProject: Record = { + "proj-1": ["cc-demo"], // the job it was finally assigned to + }; + const r = recorder({ + isCostCodeAllowed: async (projectId, costCodeId) => + (allowedByProject[projectId] ?? []).includes(costCodeId), + }); + + // Suggested "cc-plumb" while unassigned; the job it landed on has no plumbing phase. + await bookReceipt(row({ costCodeId: null, suggestedCostCodeId: "cc-plumb" }), r.deps); + assert.equal(r.expenses[0].costCodeId, null, "the stale suggestion does not survive"); + + // A code that IS a phase of the final project is kept. + const ok = recorder({ + isCostCodeAllowed: async (projectId, costCodeId) => + (allowedByProject[projectId] ?? []).includes(costCodeId), + }); + await bookReceipt(row({ costCodeId: "cc-demo" }), ok.deps); + assert.equal(ok.expenses[0].costCodeId, "cc-demo"); +}); + +// ── Confidence rides through to the booking (Phase 3 gate, b) ────────────── + +test("the phase-suggestion confidence is recorded when the suggestion is used", async () => { + const r = recorder(); + await bookReceipt(row({ costCodeId: null, suggestedConfidence: 0.82 }), r.deps); + assert.equal(r.expenses[0].costCodeId, "cc-plumb"); + assert.match(r.expenses[0].description, /phase suggested \(confidence 0\.82\)/); + assert.equal(r.events[0].detail.suggestedConfidence, 0.82); +}); + +// ── The confidence the prompt asks for is the confidence that decides ────── + +test("a LOW-confidence suggestion books UNCODED and is flagged for a human", async () => { + // read.ts tells the model "a low number sends the receipt to a human", and + // then nothing read the number: the suggestion was applied whatever it + // said, including when the model itself reported it was guessing. A phase + // the document never pointed at then rode into the Expense and every + // variance report counted it as spend on a line nobody budgeted — silently, + // because the audit note said "phase suggested" either way. + const r = recorder(); + await bookReceipt(row({ costCodeId: null, suggestedConfidence: 0.42 }), r.deps); + assert.equal(r.expenses[0].costCodeId, null, "the Expense books UNCODED"); + assert.match(r.expenses[0].description, /phase suggestion withheld \(confidence 0\.42 < 0\.6\)/); + assert.match(r.expenses[0].description, /assign one/, "and says what a human must do"); + assert.equal(r.events[0].detail.phaseRejected, "cc-plumb", "the discarded suggestion is auditable"); + assert.equal(r.events[0].detail.suggestedConfidence, undefined); +}); + +test("NO confidence at all is not 'sure' — it books UNCODED too", async () => { + // null is an ABSENT answer (an older prompt, a truncated response, a phase + // list that was never sent). Letting it clear the bar would apply exactly + // the suggestions we know least about. + const r = recorder(); + await bookReceipt(row({ costCodeId: null, suggestedConfidence: null }), r.deps); + assert.equal(r.expenses[0].costCodeId, null); + assert.match(r.expenses[0].description, /phase suggestion withheld \(confidence none stated < 0\.6\)/); + assert.equal(r.events[0].detail.phaseRejected, "cc-plumb"); +}); + +test("exactly AT the threshold is confident enough — the boundary is inclusive", async () => { + const r = recorder(); + await bookReceipt(row({ costCodeId: null, suggestedConfidence: 0.6 }), r.deps); + assert.equal(r.expenses[0].costCodeId, "cc-plumb"); + assert.match(r.expenses[0].description, /phase suggested \(confidence 0\.60\)/); +}); + +test("a HUMAN's explicit pick is never subject to the threshold", async () => { + // It is not a suggestion. A bookkeeper who codes a receipt by hand must not + // have it withheld because the model was unsure about a phase nobody asked + // it about. + const r = recorder(); + await bookReceipt(row({ costCodeId: "cc-chosen", suggestedConfidence: 0.01 }), r.deps); + assert.equal(r.expenses[0].costCodeId, "cc-chosen"); + assert.ok(!/withheld/.test(r.expenses[0].description)); +}); + +test("the threshold is env-overridable, and a junk override is ignored", () => { + assert.equal(phaseConfidenceMin(undefined), RECEIPT_PHASE_CONFIDENCE_MIN); + assert.equal(phaseConfidenceMin(""), RECEIPT_PHASE_CONFIDENCE_MIN); + assert.equal(phaseConfidenceMin("0.8"), 0.8); + assert.equal(phaseConfidenceMin("0"), 0, "zero is a real choice: apply every suggestion"); + assert.equal(phaseConfidenceMin("1"), 1); + for (const junk of ["banana", "-0.1", "1.5", "NaN", "Infinity"]) { + assert.equal(phaseConfidenceMin(junk), RECEIPT_PHASE_CONFIDENCE_MIN, junk); + } +}); + +test("null confidence never clears the bar, whatever the bar is", () => { + assert.equal(phaseSuggestionIsConfident(null, 0), false, "not even at zero"); + assert.equal(phaseSuggestionIsConfident(undefined, 0), false); + assert.equal(phaseSuggestionIsConfident(0, 0), true, "but a stated zero does"); + assert.equal(phaseSuggestionIsConfident(0.59, 0.6), false); + assert.equal(phaseSuggestionIsConfident(0.6, 0.6), true); +}); + +test("a human's explicit pick is not labelled a suggestion", async () => { + const r = recorder(); + await bookReceipt(row({ costCodeId: "cc-chosen" }), r.deps); + assert.equal(r.expenses[0].costCodeId, "cc-chosen"); + assert.ok(!/phase suggested/.test(r.expenses[0].description)); + assert.equal(r.events[0].detail.suggestedConfidence, undefined); +}); + +// ── sendAttempted is marked at the LAST possible moment (round-8 item 4) ──── + +test("a token failure leaves sendAttempted UNSET, so the key is released", async () => { + // Marking before the token refresh meant a refresh that threw left + // sendAttempted=true on a row that never reached QuickBooks — and its + // strong key was then held forever against a Purchase that does not exist. + const r = recorder({ + getTokens: async () => { throw new Error("QBNotConnectedError"); }, + }); + const result = await bookReceipt(row({ attempts: 19 }), r.deps); + assert.deepEqual(r.sendMarks, [], "never marked — nothing was sent"); + assert.equal(result.outcome, "needs-review"); + assert.equal((result as any).reason, "max-retries"); + assert.equal((result as any).releaseStrongKey, true, "so the key goes back"); +}); + +test("budget exhausted AFTER the token refresh also leaves it unset", async () => { + // The final runway check sits between the tokens and the create. A deferral + // there must not look like an attempted send. + const deadline = { startedAt: Date.now(), budgetMs: 55_000 }; + const r = recorder({ + deadline, + getTokens: async () => { + // Burn the remaining budget during the refresh. + deadline.startedAt = Date.now() - 60_000; + return { accessToken: "t", realmId: "r" } as any; + }, + }); + const result = await bookReceipt(row(), r.deps); + assert.deepEqual(result, { outcome: "deferred", reason: "out-of-budget" }); + assert.deepEqual(r.sendMarks, [], "never marked"); + assert.equal(r.purchaseCalls.length, 0); +}); + +test("a real create DOES mark it, before the call", async () => { + const order: string[] = []; + const r = recorder({ + createPurchase: atCreate(async (_t: any, input: any) => { + order.push("create"); + r.purchaseCalls.push(input); + return { ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment: "attached" } as any; + }) as any, + markSendAttempted: async (id: string) => { order.push("mark"); r.sendMarks.push(id); return true; }, + }); + await bookReceipt(row(), r.deps); + assert.deepEqual(order, ["mark", "create"], "marked FIRST, so a mid-create death still records it"); + assert.deepEqual(r.sendMarks, ["intake-1"]); +}); + +// ── An attachment QBO REFUSED is terminal (round-9 item 5) ───────────────── + +test("a 4xx or fault attachment failure goes to a human on the FIRST one", async () => { + // Retrying a file QuickBooks refused changes nothing except how long the + // Purchase sits in the books without its receipt. + for (const attachment of ["failed:400", "failed:413", "failed:415", "failed:fault"]) { + const r = recorder({ + createPurchase: atCreate(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment, + })) as any, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "needs-review", attachment); + assert.equal((result as any).reason, `attachment-refused:${attachment}`); + // The Purchase EXISTS, so the key is retained either way. + assert.equal((result as any).releaseStrongKey, false, attachment); + assert.equal(r.expenses.length, 0, attachment); + } +}); + +test("a 5xx or thrown attachment failure is still retried", async () => { + for (const attachment of ["failed:500", "failed:502", "failed:AbortError", "failed:TypeError"]) { + const r = recorder({ + createPurchase: atCreate(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment, + })) as any, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "retry", attachment); + } +}); + +test("isTerminalAttachmentFailure splits refusal from blip", () => { + for (const t of ["failed:400", "failed:404", "failed:413", "failed:499", "failed:fault"]) { + assert.equal(isTerminalAttachmentFailure(t), true, t); + } + for (const t of ["failed:500", "failed:503", "failed:AbortError", "failed:unknown"]) { + assert.equal(isTerminalAttachmentFailure(t), false, t); + } +}); + +// ── retry() must read the CURRENT send flag (round-9 item 2) ─────────────── + +test("attempt 20 RETAINS the key when the failure was at the create", async () => { + const r = recorder({ createPurchase: atCreate(async () => { throw new TypeError("fetch failed"); }) as any }); + const result = await bookReceipt(row({ attempts: 19, sendAttempted: false }), r.deps); + assert.equal((result as any).reason, "max-retries"); + // row.sendAttempted was false when the row was CLAIMED, but this attempt + // reached the create — so QBO may hold a Purchase and the key must stay. + assert.equal((result as any).releaseStrongKey, false, "the CURRENT send flag decides"); + assert.deepEqual(r.sendMarks, ["intake-1"]); +}); + +test("attempt 20 RETAINS the key when the failure was at the attachment leg", async () => { + const r = recorder({ + createPurchase: atCreate(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: false, attachment: "failed:500", + })) as any, + }); + const result = await bookReceipt(row({ attempts: 19, sendAttempted: false }), r.deps); + assert.equal((result as any).reason, "max-retries"); + assert.equal((result as any).releaseStrongKey, false, "the Purchase exists"); +}); + +test("attempt 20 RETAINS the key when the POST-create DB write failed", async () => { + const r = recorder(); + (r.deps.db as any).$transaction = async () => { throw new Error("connection reset"); }; + const result = await bookReceipt(row({ attempts: 19, sendAttempted: false }), r.deps); + assert.equal((result as any).reason, "max-retries"); + assert.equal((result as any).releaseStrongKey, false, "the Purchase exists even though the row does not know"); +}); + +test("attempt 20 RELEASES the key only when nothing was ever sent", async () => { + const r = recorder({ + downloadBytes: async () => ({ ok: false, kind: "transient", message: "ECONNRESET" }), + }); + const result = await bookReceipt(row({ attempts: 19, sendAttempted: false }), r.deps); + assert.equal((result as any).reason, "max-retries"); + assert.equal((result as any).releaseStrongKey, true); + assert.deepEqual(r.sendMarks, [], "never reached the create"); +}); + +// ── A superseded worker sends nothing (Phase 2 gate) ────────────────────── + +test("a STALE claim aborts BEFORE the QBO create", async () => { + // The zombie case: an invocation killed mid-booking resumes after its row + // has been re-claimed. markSendAttempted is a CAS on the claim, so it + // affects zero rows — and the booking stops THERE, having sent nothing. + // Posting a Purchase the live worker is also about to post is the + // double-booking this whole mechanism exists to prevent. + const r = recorder({ markSendAttempted: async () => false }); + const result = await bookReceipt(row(), r.deps); + + assert.deepEqual(result, { outcome: "stale" }); + assert.equal(r.purchaseCalls.length, 0, "QuickBooks is never called"); + assert.equal(r.expenses.length, 0, "no Expense"); + assert.deepEqual(r.intakeUpdates, [], "and no state write at all"); +}); + +test("the send fence receives the row's OWN claim token", async () => { + const seen: Array = []; + const r = recorder({ + markSendAttempted: async (_id, token) => { seen.push(token); return true; }, + }); + await bookReceipt(row({ claimToken: "token-xyz" }), r.deps); + assert.deepEqual(seen, ["token-xyz"]); +}); + +test("losing the claim DURING the commit rolls back and reports stale", async () => { + // The window between the create and the commit. The Purchase exists, so + // the successor's retry hits alreadyExists and books it once, under one + // owner — but THIS worker must not complete a BOOKED write. + const r = recorder(); + (r.deps.db as any).receiptIntake.updateMany = async () => ({ count: 0 }); + const result = await bookReceipt(row(), r.deps); + + assert.deepEqual(result, { outcome: "stale" }); + assert.equal(r.purchaseCalls.length, 1, "the create did happen"); + assert.equal(r.events.length, 0, "but nothing is logged as booked"); +}); + +test("the BOOKED write is a CAS on state AND token", async () => { + const wheres: any[] = []; + const r = recorder(); + (r.deps.db as any).receiptIntake.updateMany = async (args: any) => { + wheres.push(args.where); + return { count: 1 }; + }; + await bookReceipt(row({ claimToken: "token-abc" }), r.deps); + assert.deepEqual(wheres, [{ id: "intake-1", state: "BOOKING", claimToken: "token-abc" }]); +}); + +// ── A Purchase found by the idempotency query is still a Purchase ─────────── + +test("the already-exists branch does NOT go through onBeforeCreate", async () => { + // The control for every test below: if the fake called onBeforeCreate here + // the row would look "sent" for the wrong reason and the bug would be + // invisible. The real core returns from the idempotency query. + const seen: string[] = []; + const r = recorder({ + createPurchase: async (_t: any, _i: any, _d: any, onBeforeCreate: any, onExisting: any) => { + seen.push("create-called"); + await onExisting?.(); + void onBeforeCreate; + return { ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "already-attached", existing: BOOKS_AGREE } as any; + }, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.deepEqual(seen, ["create-called"]); + assert.deepEqual(r.sendMarks, ["intake-1"], "the EXISTING-purchase hook marked it, fenced the same way"); +}); + +test("attempt 20 on an ALREADY-EXISTING purchase retains the strong key", async () => { + // The hole: this path never reaches the create, so `sendAttempted` stayed + // false — and a row that exhausted its retries here handed its dedup key + // back while a real Purchase sat in the books. The next submission of the + // same receipt would then book it a second time. + const r = recorder({ + createPurchase: atExisting(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "failed:500", existing: BOOKS_AGREE, + })) as any, + }); + const result = await bookReceipt(row({ attempts: 19, sendAttempted: false }), r.deps); + assert.equal((result as any).reason, "max-retries"); + assert.equal((result as any).releaseStrongKey, false, "the Purchase EXISTS"); + assert.deepEqual(r.sendMarks, ["intake-1"], "and the flag was persisted, not just held in memory"); +}); + +test("a terminal attachment refusal on an existing purchase also retains the key", async () => { + const r = recorder({ + createPurchase: atExisting(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "failed:415", existing: BOOKS_AGREE, + })) as any, + }); + const result = await bookReceipt(row(), r.deps); + assert.equal((result as any).reason, "attachment-refused:failed:415"); + assert.equal((result as any).releaseStrongKey, false); +}); + +test("a STALE claim on the existing-purchase hook aborts, exactly like the create hook", async () => { + const r = recorder({ + markSendAttempted: async () => false, + createPurchase: atExisting(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, attachment: "already-attached", existing: BOOKS_AGREE, + })) as any, + }); + assert.deepEqual(await bookReceipt(row(), r.deps), { outcome: "stale" }); + assert.equal(r.expenses.length, 0, "a superseded worker books nothing"); +}); + +test("the QBO core fires onExistingPurchase before it touches the attachment", () => { + // Ordering matters: the attachment re-check is a round trip that can fail, + // and the caller still has to know a Purchase is there. + const source = readFileSync( + path.join(__dirname, "..", "src/lib/qbo-receipt-push.ts"), + "utf8", + ); + const branch = source.slice(source.indexOf("if (existing.length > 0) {")); + const body = branch.slice(0, branch.indexOf("alreadyExists: true")); + assert.match(body, /await deps\.onExistingPurchase\?\.\(\);/); + assert.ok( + body.indexOf("onExistingPurchase") < body.indexOf("ensureAttachmentOnExistingPurchase"), + "the signal precedes the attachment work", + ); + // And the create hook is NOT fired on this path. + assert.ok(!body.includes("onBeforeCreate"), "onBeforeCreate belongs to the create path only"); +}); + +// -- An EXISTING QBO Purchase decides the Expense (round-34 item 2) ---------- + +/** + * `alreadyExists` is not only the lost-response retry. It is every v1-cutover + * document (the Apps Script posted the Purchase from its OWN read of the file) + * and every Drive revision that kept its fileId. The Expense used to be written + * from THIS pass's OCR values regardless, so ProBuild's job cost could carry a + * total, a date or a job QuickBooks does not have — under a `qbPurchaseId` that + * says the two are the same document. + */ +function existingPurchase(existing: unknown) { + return atExisting(async () => ({ + ok: true, qbPurchaseId: "QB-1", docNumber: "d", alreadyExists: true, + attachment: "already-attached", existing, + })) as any; +} + +test("a matching Purchase books exactly as it always did", async () => { + // The control. Without it every assertion below would pass on a code path + // that had simply stopped booking. + const r = recorder({ createPurchase: existingPurchase(BOOKS_AGREE) }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal(r.expenses.length, 1); + assert.equal(r.expenses[0].amount, 364.98); + assert.equal(r.expenses[0].vendor, "Lowes"); + assert.ok(!/existing QuickBooks Purchase/.test(r.expenses[0].description)); + assert.equal(r.events[0].detail.qboDerivedFields, undefined); +}); + +test("AMOUNT: the Expense is DERIVED from the books, not from the read", async () => { + // Real money posted against QBO's number, and the Expense is linked to that + // very Purchase. Writing 364.98 next to a Purchase the books say is 372.10 + // is a variance report that can never tie out. + const r = recorder({ createPurchase: existingPurchase(booksSay("derive", ["amount"], { totalAmount: 372.1 })) }); + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked"); + assert.equal(r.expenses.length, 1); + assert.equal(r.expenses[0].amount, 372.1, "the books' total, not the OCR one"); + assert.match( + r.expenses[0].description, + /amount taken from the existing QuickBooks Purchase/, + "and it says so, so a bookkeeper reading the Expense knows why", + ); + assert.equal(r.events[0].amountCents, 37210, "the audit row records what was booked"); + assert.deepEqual(r.events[0].detail.qboDerivedFields, ["amount"]); +}); + +test("DATE and VENDOR are derived the same way", async () => { + const r = recorder({ + createPurchase: existingPurchase(booksSay("derive", ["date", "vendor"], { + txnDate: "2026-08-01", + vendor: "Lowe's Home Improvement #1234", + })), + }); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal(r.expenses[0].vendor, "Lowe's Home Improvement #1234"); + // Re-anchored to the company's calendar day, exactly as the normal path + // does — the derived day goes through the same conversion, not around it. + assert.equal( + (r.expenses[0].date as Date).toISOString(), + startOfDateInTimeZone("2026-08-01", "America/Los_Angeles").toISOString(), + ); +}); + +test("PROJECT: a disagreement parks the row and writes NO Expense", async () => { + // Which job carries the cost is an attribution decision. Deriving it would + // move money between jobs on QuickBooks' say-so; using the read would file + // it under a job the books disagree with. Neither is ours to choose. + const r = recorder({ createPurchase: existingPurchase(booksSay("review", ["project"], { projectNames: ["Mesplay Kitchen"] })) }); + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "needs-review"); + assert.equal((result as any).reason, "qbo-purchase-mismatch:project"); + assert.equal( + (result as any).releaseStrongKey, + false, + "a Purchase provably exists, so the dedup key must NOT go back", + ); + assert.equal(r.expenses.length, 0, "nothing is written from the read"); + assert.equal(r.intakeUpdates.length, 0, "and the row is not marked BOOKED"); +}); + +test("TAX: a split the books do not have parks too, naming every difference", async () => { + const r = recorder({ createPurchase: existingPurchase(booksSay("review", ["project", "tax"])) }); + const result = await bookReceipt(row(), r.deps); + assert.equal((result as any).reason, "qbo-purchase-mismatch:project,tax"); + assert.equal(r.expenses.length, 0); +}); + +test("the mismatch park is NOT recoverable by a re-upload", async () => { + // A re-upload of the same bytes changes nothing about the books, and + // dragging the row back to RECEIVED would re-read it into the same + // disagreement. It is a human's decision, like every other non-sweeper park. + assert.ok(!RECOVERABLE_PARK_REASONS.some(reason => QBO_PURCHASE_MISMATCH_PREFIX.startsWith(reason))); + assert.equal( + finalizeDisposition({ + state: "NEEDS_REVIEW", + stateReason: `${QBO_PURCHASE_MISMATCH_PREFIX}project`, + }), + "not-recoverable", + ); +}); + +test("a FRESHLY created Purchase never consults the books — there is nothing to consult", async () => { + // alreadyExists:false carries no comparison at all: this call wrote the + // Purchase from these very values, so they agree by construction. + const r = recorder(); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal(r.expenses[0].amount, 364.98); + assert.equal(r.events[0].detail.qboDerivedFields, undefined); +}); + +// ── Tolerance is for IDENTITY, not for VALUES (Codex round-13 items 1 & 2) ── +// +// `compareExistingPurchase` calls a Purchase the SAME purchase when it is +// within two cents on the amount or the tax, and when the vendor differs only +// in case or spacing. That tolerance exists so a rounding split or a +// capitalisation difference does not send an ordinary receipt to a human. It +// says nothing about which numbers to STORE. +// +// Adopting QBO's values only on `derive` meant a verdict of `match`: +// - wrote the OCR total into job cost while QuickBooks held a figure a cent +// away, under a qbPurchaseId asserting the two are one document; +// - logged the OCR tax to the audit register as "what posted", so the +// sales-tax filing report reconciled against a number no Purchase carried; +// - and, on the importer-won crash gap, met the importer's QBO-sourced +// Expense at the exact comparison in reconcileExistingExpense and parked a +// receipt nothing was wrong with. + +/** A Purchase QBO posted a hair away from the OCR read — still ONE purchase. */ +const booksWithin = (booked: Partial) => ({ + verdict: "match" as const, + differences: [] as string[], + booked: { ...BOOKS_AGREE.booked, ...booked }, +}); + +/** The deps override for a Purchase already in the books. */ +const fromBooks = (existing: unknown) => ({ createPurchase: existingPurchase(existing) }); + +for (const [label, deltaCents] of [["+1c", 1], ["-1c", -1], ["+2c", 2], ["-2c", -2]] as const) { + test(`MATCHED ${label} on the AMOUNT: the Expense carries QBO's number, not the OCR one`, async () => { + // row() reads 364.98; QBO posted a cent or two away and the identity + // check still says "same purchase". + const postedCents = 36498 + deltaCents; + const r = recorder(fromBooks(booksWithin({ totalAmount: postedCents / 100 }))); + + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked", label); + assert.equal(r.expenses.length, 1); + assert.equal( + Math.round(Number(r.expenses[0].amount) * 100), + postedCents, + `${label}: job cost records what QuickBooks actually posted`, + ); + // THE PRE-FIX CONTROL: the OCR figure is a DIFFERENT number, so this + // assertion cannot pass for code that simply kept the read. + assert.notEqual(postedCents, 36498, "the two really do differ"); + assert.equal(r.events[0].amountCents, postedCents, "and the audit reports the same"); + }); +} + +for (const [label, deltaCents] of [["+1c", 1], ["-2c", -2]] as const) { + test(`MATCHED ${label} on the TAX: the audit reports QBO's tax, not the read's`, async () => { + const postedTaxCents = 2920 + deltaCents; + const r = recorder(fromBooks(booksWithin({ taxAmount: postedTaxCents / 100 }))); + + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked", label); + assert.equal( + r.events[0].taxCents, + postedTaxCents, + `${label}: the filing register reconciles against the Purchase`, + ); + assert.notEqual(postedTaxCents, 2920, "the OCR tax is a different number"); + }); +} + +test("CONTROL: a fresh create still reports the read's own numbers", async () => { + // Nothing was in the books, so there is no posted figure to adopt and the + // groups this pass actually sent are the truth. Without this control, code + // that always reached for `booked` would pass every test above. + const r = recorder(); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal(Math.round(Number(r.expenses[0].amount) * 100), 36498); + assert.equal(r.events[0].taxCents, 2920); +}); + +test("a DERIVE verdict adopts exactly as a MATCH does, and still names the fields", async () => { + // The two verdicts must not disagree about what gets stored — only about + // what gets REPORTED as a difference. + const r = recorder(fromBooks( + booksSay("derive", ["amount"], { totalAmount: 401.11 }), + )); + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + assert.equal(Math.round(Number(r.expenses[0].amount) * 100), 40111); + assert.deepEqual(r.events[0].detail.qboDerivedFields, ["amount"]); +}); + +test("a MATCH adopts silently — no derived-fields noise for a within-tolerance cent", async () => { + // `differences` is the beyond-tolerance list. A one-cent adoption is not a + // disagreement anyone needs to review, so it must not fill the register + // with derived-field rows. + const r = recorder(fromBooks(booksWithin({ totalAmount: 364.99 }))); + await bookReceipt(row(), r.deps); + assert.equal(r.events[0].detail.qboDerivedFields, undefined); +}); + +test("IMPORTER WINS at ±1c: the retry reconciles against QBO's number, not the read's", async () => { + // The whole failure, end to end. QBO posted 364.99; the importer wrote that + // into the Expense; the OCR read says 364.98. Before the fix the worker + // compared its OCR cents to the importer's QBO cents and parked + // `expense-conflict:amount` — a receipt that was never wrong about anything. + const r = recorder( + fromBooks(booksWithin({ totalAmount: 364.99 })), + { existingExpense: importedExpense({ amount: 364.99 }) }, + ); + + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked", "it recovers instead of parking"); + assert.equal((result as any).expenseId, "exp-existing"); + assert.equal(r.expenses.length, 0, "no duplicate Expense"); + // CONTROL: the same importer row against the OCR figure IS a conflict, so + // this is not passing because the reconcile stopped comparing amounts. + const wouldPark = reconcileExistingExpense( + importedExpense({ amount: 364.99 }) as never, + { ...receiptValues(), amountCents: 36498 }, + ); + assert.deepEqual(wouldPark.conflicts, ["amount"], "the OCR comparison would have parked"); +}); + +test("IMPORTER WINS on VENDOR SPELLING: canonical vs OCR recovers, not parks", async () => { + // `compareExistingPurchase` normalizes case and whitespace, so QBO's + // canonical "Home Depot" and the receipt's " home depot " are ONE vendor + // to the identity check. The importer wrote QBO's spelling; the reconcile + // compared byte-for-byte and parked `expense-conflict:vendor`. + const r = recorder( + fromBooks(booksWithin({ vendor: "Home Depot" })), + { existingExpense: importedExpense({ vendor: "Home Depot" }) }, + ); + + const result = await bookReceipt(row({ vendor: " home depot " }), r.deps); + + assert.equal(result.outcome, "booked", "it recovers instead of parking"); + assert.equal((result as any).expenseId, "exp-existing"); + // The persisted vendor is QBO's canonical display name on every path that + // identified an existing Purchase — nothing rewrites the books, but job + // cost must not carry a spelling QuickBooks does not use. + assert.ok( + !r.expenseUpdates.some(u => "vendor" in u), + "the importer's canonical spelling stands", + ); +}); + +test("ONE vendor normalizer, and it is the identity check's", async () => { + // The two comparisons must never be able to disagree again. Asserted on + // the shared function directly, and on the reconcile that now calls it. + assert.equal(normalizeVendorName(" home depot "), normalizeVendorName("Home Depot")); + const spelled = reconcileExistingExpense( + importedExpense({ vendor: "Home Depot" }) as never, + { ...receiptValues(), vendor: " home depot " }, + ); + assert.deepEqual(spelled.conflicts, [], "case and spacing are not a contradiction"); + // CONTROL: a genuinely different vendor still is. + const different = reconcileExistingExpense( + importedExpense({ vendor: "Home Depot" }) as never, + { ...receiptValues(), vendor: "Lowes" }, + ); + assert.deepEqual(different.conflicts, ["vendor"]); +}); + +test("a fresh create on an alreadyExists purchase stores QBO's vendor spelling", async () => { + // No importer row this time: the Expense is created here, so the value + // written is the one this file chose. It must still be QBO's. + const r = recorder(fromBooks(booksWithin({ vendor: "Home Depot" }))); + await bookReceipt(row({ vendor: " home depot " }), r.deps); + assert.equal(r.expenses[0].vendor, "Home Depot"); +}); + +// ── The audit must report the phase that was PERSISTED (round-13 item 3) ─── + +test("a preserved human phase is what the booking event reports", async () => { + // The worker picks cc-plumb; the row already carries a human's + // cc-electrical, which the reconcile keeps. The event used to log the + // worker's pick regardless — asserting a cost code never applied to + // anything, and attaching the model's confidence score to it. + const r = recorder( + fromBooks(BOOKS_AGREE), + { existingExpense: importedExpense({ costCodeId: "cc-electrical" }) }, + ); + + const result = await bookReceipt(row(), r.deps); + + assert.equal(result.outcome, "booked"); + const detail = r.events[0].detail; + assert.equal(detail.costCodeId, "cc-electrical", "the persisted phase, not the worker's"); + assert.equal(detail.costCodeSource, "existing"); + assert.equal(detail.phasePreserved, true, "and it says so explicitly"); + // CONTROL: the worker really did pick something else, so this cannot pass + // for an event that simply echoed whatever the row already had. + assert.equal(row().suggestedCostCodeId, "cc-plumb"); + assert.equal( + detail.suggestedConfidence, + undefined, + "a confidence score for a suggestion that lost describes nothing", + ); +}); + +test("an unclaimed phase is FILLED, and reported as the receipt's", async () => { + const r = recorder(fromBooks(BOOKS_AGREE), { existingExpense: importedExpense() }); + await bookReceipt(row(), r.deps); + const detail = r.events[0].detail; + assert.equal(detail.costCodeId, "cc-plumb"); + assert.equal(detail.costCodeSource, "receipt"); + assert.equal(detail.phasePreserved, undefined, "nothing was displaced"); + assert.equal(detail.suggestedConfidence, 0.82, "and the confidence still rides along"); +}); + +test("a phase the row already agrees with is not 'preserved'", async () => { + // Only a CONTEST counts. Marking every existing value as preserved would + // make the flag useless for finding the cases a human should look at. + const r = recorder( + fromBooks(BOOKS_AGREE), + { existingExpense: importedExpense({ costCodeId: "cc-plumb" }) }, + ); + await bookReceipt(row(), r.deps); + assert.equal(r.events[0].detail.phasePreserved, undefined); + assert.equal(r.events[0].detail.costCodeId, "cc-plumb"); +}); + +test("reconcile reports the effective attribution, three ways", () => { + const withHuman = reconcileExistingExpense( + importedExpense({ costCodeId: "cc-electrical" }) as never, + receiptValues(), + ); + assert.deepEqual(withHuman.attribution, { + costCodeId: "cc-electrical", costCodeSource: "existing", preserved: true, + }); + // No contest: the receipt had nothing to offer. + const noSuggestion = reconcileExistingExpense( + importedExpense({ costCodeId: "cc-electrical" }) as never, + { ...receiptValues(), costCodeId: null }, + ); + assert.equal(noSuggestion.attribution.preserved, false); + // Filled from the receipt. + const filled = reconcileExistingExpense(importedExpense() as never, receiptValues()); + assert.deepEqual(filled.attribution, { + costCodeId: "cc-plumb", costCodeSource: "receipt", preserved: false, + }); + // Neither side has one. + const neither = reconcileExistingExpense( + importedExpense() as never, + { ...receiptValues(), costCodeId: null }, + ); + assert.deepEqual(neither.attribution, { + costCodeId: null, costCodeSource: "none", preserved: false, + }); +}); + +// ── The intake row records what POSTED (Codex round-16 item 3) ───────────── +// +// "QuickBooks is authoritative for an existing Purchase" was only half true: +// booking derived the total, vendor, date and tax from QBO and wrote them to +// the Expense, but left the intake row carrying the OCR read. So `taxCents` — +// the column Phase 3's sales-tax reporting is specified to read — kept a +// figure no Purchase ever posted, and the row disagreed with its own Expense +// under a qbPurchaseId asserting the two are one document. The audit event +// reported `row.vendor` (the OCR spelling) while the Expense carried QBO's. + +test("BOOKED persists QBO's tax, vendor, total and date on the intake row", async () => { + // The read says Lowes / 364.98 / 29.20; QuickBooks posted Home Depot / + // 372.10 / 31.00 on a different day, and identified the same purchase. + const r = recorder(fromBooks(booksSay("derive", ["amount", "vendor", "date"], { + totalAmount: 372.10, + vendor: "Home Depot", + txnDate: "2026-08-04", + taxAmount: 31.0, + }))); + + const result = await bookReceipt(row(), r.deps); + assert.equal(result.outcome, "booked"); + + const booked = r.intakeUpdates.find(u => u.state === "BOOKED"); + assert.ok(booked, "the row reached BOOKED"); + assert.equal(booked.vendor, "Home Depot", "QBO's vendor, not the read's"); + assert.equal(booked.totalCents, 37210); + assert.equal(booked.taxCents, 3100, "the column Phase 3 reads"); + assert.equal( + (booked.txnDate as Date).toISOString().slice(0, 10), + "2026-08-04", + "and QBO's calendar day", + ); + + // PRE-FIX CONTROL: every one of those is a DIFFERENT value from the OCR + // read, so these assertions cannot pass for code that left the row alone. + assert.equal(row().vendor, "Lowes"); + assert.equal(row().totalCents, 36498); + assert.equal(row().taxCents, 2920); +}); + +test("the audit event reports exactly what the Expense got", async () => { + const r = recorder(fromBooks(booksSay("derive", ["amount", "vendor"], { + totalAmount: 372.10, + vendor: "Home Depot", + taxAmount: 31.0, + }))); + await bookReceipt(row(), r.deps); + + const event = r.events[0]; + const expense = r.expenses[0]; + assert.equal(event.vendor, "Home Depot", "not row.vendor"); + assert.equal(event.vendor, expense.vendor, "the event and the Expense agree"); + assert.equal(event.amountCents, 37210); + assert.equal(Math.round(Number(expense.amount) * 100), event.amountCents); + assert.equal(event.taxCents, 3100); +}); + +test("the row, the Expense and the audit are ONE object — asserted together", async () => { + // The property that keeps them from drifting again: all three writes read + // the same `booked`, so any disagreement is a code change, not a + // maintenance slip in one of three places. + const r = recorder(fromBooks(booksWithin({ totalAmount: 364.99, taxAmount: 29.25 }))); + await bookReceipt(row(), r.deps); + + const rowUpdate = r.intakeUpdates.find(u => u.state === "BOOKED")!; + const expense = r.expenses[0]; + const event = r.events[0]; + assert.equal(rowUpdate.totalCents, 36499); + assert.equal(Math.round(Number(expense.amount) * 100), 36499); + assert.equal(event.amountCents, 36499); + assert.equal(rowUpdate.taxCents, 2925); + assert.equal(event.taxCents, 2925); + assert.equal(rowUpdate.vendor, expense.vendor); +}); + +test("a FRESH create still records the read's own values", async () => { + // Nothing was in the books, so there is no posted figure to adopt — the + // control that stops the fix from reaching for `booked` unconditionally. + const r = recorder(); + await bookReceipt(row(), r.deps); + const rowUpdate = r.intakeUpdates.find(u => u.state === "BOOKED")!; + assert.equal(rowUpdate.vendor, "Lowes"); + assert.equal(rowUpdate.totalCents, 36498); + assert.equal(rowUpdate.taxCents, 2920); +}); + +test("readJson is NEVER rewritten, so the OCR original stays auditable", async () => { + // BOOKED overwrites the extracted values on purpose; this is what makes + // that safe. There is no `extracted*` column pair on the model and none is + // needed while the raw model response survives verbatim. + const r = recorder(fromBooks(booksSay("derive", ["vendor"], { vendor: "Home Depot" }))); + await bookReceipt(row(), r.deps); + for (const update of r.intakeUpdates) { + assert.ok(!("readJson" in update), "no booking write touches the raw read"); + } +}); diff --git a/tests/receipt-intake-claim-db.test.ts b/tests/receipt-intake-claim-db.test.ts new file mode 100644 index 000000000..d705200d7 --- /dev/null +++ b/tests/receipt-intake-claim-db.test.ts @@ -0,0 +1,1205 @@ +/** + * The claim transaction against a REAL Postgres. + * + * Everything else in this feature's suites mocks the database, so the SQL is + * the one part they cannot check — and both bugs that live there are silent + * until production: a void-returning function read through $queryRaw, and a + * claim whose real behaviour differs from the mocked stand-in. + * + * Opt-in by design: it needs a THROWAWAY database and it writes rows. It runs + * in CI's migrations job (which has a disposable Postgres) and skips everywhere + * else, including anywhere DATABASE_URL looks like production. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { PrismaClient, Prisma } from "@prisma/client"; +import { CLAIM_LOCK_KEY, eligibleClaimWhere } from "../src/lib/receipt-intake/worker"; +import { lockQboExpense } from "../src/lib/qbo-expense-sync"; +import { sealAndPublish } from "../src/lib/receipt-intake/stored-object"; +import { statements, verifyColumnDefaults } from "../scripts/apply-receipt-intake.mjs"; +import { + acquireObjectClaim, + DELETE_CLAIM_LEASE_MS, + renewObjectClaim, +} from "../src/lib/receipt-intake/storage-cleanup"; +import { STORAGE_CALL_MAX_MS } from "../src/lib/receipt-intake/bucket"; +import { reconcileExistingExpense } from "../src/lib/receipt-intake/book"; + +const url = process.env.RECEIPT_INTAKE_DB_TEST_URL ?? process.env.MIGRATION_HISTORY_TEST_URL; +const looksLikeProd = !!url && /supabase\.(co|com)/i.test(url); +const skip = !url + ? "set RECEIPT_INTAKE_DB_TEST_URL to a disposable PostgreSQL URL" + : looksLikeProd + ? "refusing to run against what looks like production" + : false; + +const db = url && !looksLikeProd ? new PrismaClient({ datasources: { db: { url } } }) : null; +/** + * TWO MORE CONNECTIONS. The claim race is between two transactions on two + * different backends, and a single client cannot hold two open at once -- + * which is exactly why a mocked transaction can say nothing about it. + */ +const dbA = url && !looksLikeProd ? new PrismaClient({ datasources: { db: { url } } }) : null; +const dbB = url && !looksLikeProd ? new PrismaClient({ datasources: { db: { url } } }) : null; + +const PREFIX = "drive:claimdb-"; + +async function seed(id: string, over: Partial = {}) { + return db!.receiptIntake.create({ + data: { + id, + source: "drive", + sourceRef: `${PREFIX}${id}`, + state: "RECEIVED", + dryRun: false, + storagePath: `receipts/intake/${id}.jpg`, + mimeType: "image/jpeg", + fileSize: 10, + fileSha256: "x".repeat(64), + ...over, + }, + }); +} + +test("the blocking advisory lock runs without error inside a transaction", { skip }, async () => { + // The regression: `SELECT pg_advisory_xact_lock(...)` through $queryRaw. + // pg_advisory_xact_lock returns VOID, and reading that column can throw — + // inside the promotion transaction, which then looks like a transient DB + // fault forever while the lock was never taken. + await db!.$transaction(async tx => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${"weak-key-probe"}, 0))`; + }); + + // ...and the TRY variant genuinely returns a readable boolean. + const [row] = await db!.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${CLAIM_LOCK_KEY}, 0)) AS locked`, + ); + assert.equal(typeof row.locked, "boolean"); +}); + +test("two overlapping claims never hand out the same row", { skip }, async () => { + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); + const ids = ["claimdb-a", "claimdb-b", "claimdb-c"]; + for (const id of ids) await seed(id); + + const now = new Date(); + const claimOnce = async () => + db!.$transaction(async tx => { + const [lock] = await tx.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${CLAIM_LOCK_KEY}, 0)) AS locked`, + ); + if (!lock?.locked) return null; + const due = await tx.receiptIntake.findMany({ + // The SHIPPED predicate, not a copy of it. A local re-statement + // is how the claim and the worker loop came to disagree about + // which rows are workable (finding 1); this suite is the only + // place the real SQL is ever executed, so it must execute the + // real thing. + where: { sourceRef: { startsWith: PREFIX }, ...eligibleClaimWhere(now, false) }, + select: { id: true }, + }); + if (due.length === 0) return []; + await tx.receiptIntake.updateMany({ + where: { id: { in: due.map(r => r.id) } }, + data: { nextRetryAt: new Date(now.getTime() + 10 * 60_000) }, + }); + return due.map(r => r.id); + }); + + const first = await claimOnce(); + const second = await claimOnce(); + + assert.deepEqual([...(first ?? [])].sort(), ids.slice().sort(), "the first claim takes them"); + // The lease is what guarantees this, not the lock: the lock is released the + // moment the first transaction commits. + assert.deepEqual(second, [], "the second claim finds nothing left"); + + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); +}); + +test("a row whose nextRetryAt moved into the future between select and claim is skipped", { skip }, async () => { + // The regression: claim() used to SELECT the due rows and then claim them + // by id ALONE — `updateMany({ where: { id: { in: ids } } })` — instead of + // re-checking the same eligibility predicate. Anything that touches + // nextRetryAt/state WITHOUT going through the advisory lock (a late + // retryRow, a deferRead, an admin "snooze") can still land between those + // two statements even inside one transaction, under READ COMMITTED. The + // fixed claim re-checks the predicate in the UPDATE itself, so a row that + // moved out of eligibility in that window is left untouched. + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); + const ids = ["claimdb-race-a", "claimdb-race-b"]; + for (const id of ids) await seed(id); + + const now = new Date(); + const ELIGIBLE = { sourceRef: { startsWith: PREFIX }, ...eligibleClaimWhere(now, false) }; + + const due = await db!.receiptIntake.findMany({ where: ELIGIBLE, select: { id: true } }); + assert.deepEqual(due.map(r => r.id).sort(), ids.slice().sort(), "both rows start eligible"); + + // INTERLEAVING: between the select above and the claim below, something + // else (not this claim, not under its lock) pushes one row's lease into + // the future — exactly what a concurrent, unrelated write would do. + const future = new Date(now.getTime() + 30 * 60_000); + await db!.receiptIntake.update({ + where: { id: "claimdb-race-a" }, + data: { nextRetryAt: future }, + }); + + const claimToken = "race-token"; + const claimed = await db!.receiptIntake.updateMany({ + where: { id: { in: due.map(r => r.id) }, ...ELIGIBLE }, + data: { nextRetryAt: new Date(now.getTime() + 10 * 60_000), claimToken, claimedAt: now }, + }); + assert.equal(claimed.count, 1, "only the row that is STILL eligible is claimed"); + + const won = await db!.receiptIntake.findMany({ + where: { id: { in: due.map(r => r.id) }, claimToken }, + select: { id: true }, + }); + assert.deepEqual(won.map(r => r.id), ["claimdb-race-b"], "the raced row is skipped, not blindly reclaimed"); + + // The raced row is untouched by the claim: it keeps the future lease the + // interleaving write set, and never picked up the claim token at all. + const racedRow = await db!.receiptIntake.findUnique({ where: { id: "claimdb-race-a" } }); + assert.equal(racedRow?.nextRetryAt?.getTime(), future.getTime()); + assert.equal(racedRow?.claimToken, null); + + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); +}); + +test("DRY-RUN ROLLBACK: the QBO-writing states are not claimable, whatever the row flag says", { skip }, async () => { + // Finding 1, against real SQL. A live window left rows at READ/BOOKING with + // dryRun=false; the switch is then rolled back. Those rows must drop out of + // the claim entirely, or they fill every ten-row batch (oldest-first) and + // the RECEIVED receipts behind them are never read. + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); + await seed("claimdb-old-read", { state: "READ", dryRun: false }); + await seed("claimdb-old-booking", { state: "BOOKING", dryRun: false }); + await seed("claimdb-parked", { state: "READ", dryRun: true }); + await seed("claimdb-new", { state: "RECEIVED", dryRun: true }); + + const now = new Date(); + const claimable = async (dryRunGlobal: boolean) => + (await db!.receiptIntake.findMany({ + where: { sourceRef: { startsWith: PREFIX }, ...eligibleClaimWhere(now, dryRunGlobal) }, + select: { id: true }, + })).map(r => r.id).sort(); + + assert.deepEqual( + await claimable(true), + ["claimdb-new"], + "under dry-run only the new receipt is claimable", + ); + // And the exclusion is not a black hole: flip the switch and the same rows + // are claimable again. Only the shadow-week park (dryRun=true at READ) stays + // out, until the cutover requeues it. + assert.deepEqual( + await claimable(false), + ["claimdb-new", "claimdb-old-booking", "claimdb-old-read"], + ); + + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); +}); + +test("SHADOW_DONE and the new columns are writable — the CHECK really allows them", { skip }, async () => { + // The cutover writes SHADOW_DONE on every shadow row in ONE statement. If + // the CHECK constraint did not allow it, that fails inside the claim + // transaction and takes the whole cutover with it. + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); + await seed("claimdb-shadow", { + state: "SHADOW_DONE", + stateReason: "booked-by-v1", + archivedByV1: true, + sendAttempted: false, + expectedSha256: "y".repeat(64), + }); + const row = await db!.receiptIntake.findUnique({ where: { id: "claimdb-shadow" } }); + assert.equal(row?.state, "SHADOW_DONE"); + assert.equal(row?.archivedByV1, true); + assert.equal(row?.expectedSha256, "y".repeat(64)); + + // And an invented state is still refused. + await assert.rejects( + () => seed("claimdb-bogus", { state: "NOT_A_STATE" }), + /violates check constraint|check constraint/i, + ); + + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); +}); + +test.after(async () => { + if (db) { + await db.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }).catch(() => {}); + await db.$disconnect(); + } +}); + +test("the finishRouting adapter is fenced on BOTH state and token", { skip }, async () => { + // The production adapter, against a real database — the mocked worker + // suites cannot see this, and the failure it prevents is a zombie worker + // publishing READ over the state its successor already produced. + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); + + const finishRouting = async (rowId: string, claimToken: string | null, stateReason: string | null) => { + const { count } = await db!.receiptIntake.updateMany({ + where: { id: rowId, state: "RECEIVED", claimToken }, + data: { state: "READ", stateReason, nextRetryAt: null, claimToken: null, claimedAt: null }, + }); + return count; + }; + + // The happy path: the holder of the current token publishes and BOTH claim + // fields are cleared. + await seed("claimdb-fence", { claimToken: "token-1", claimedAt: new Date(), nextRetryAt: new Date() }); + assert.equal(await finishRouting("claimdb-fence", "token-1", null), 1); + const published = await db!.receiptIntake.findUnique({ where: { id: "claimdb-fence" } }); + assert.equal(published?.state, "READ"); + assert.equal(published?.claimToken, null, "the token is released"); + assert.equal(published?.claimedAt, null, "and so is claimedAt"); + assert.equal(published?.nextRetryAt, null, "and the lease"); + + // The zombie: a stale token writes NOTHING, even though the row is back in + // RECEIVED and looks claimable to a state-only check. + await db!.receiptIntake.update({ + where: { id: "claimdb-fence" }, + data: { state: "RECEIVED", claimToken: "token-2", stateReason: null }, + }); + assert.equal(await finishRouting("claimdb-fence", "token-1", "zombie"), 0, "stale token is fenced out"); + const afterZombie = await db!.receiptIntake.findUnique({ where: { id: "claimdb-fence" } }); + assert.equal(afterZombie?.state, "RECEIVED", "the successor's state survives"); + assert.equal(afterZombie?.claimToken, "token-2", "and its claim is untouched"); + + // The state fence still holds independently: right token, wrong state. + await db!.receiptIntake.update({ + where: { id: "claimdb-fence" }, + data: { state: "NEEDS_REVIEW", claimToken: "token-3" }, + }); + assert.equal(await finishRouting("claimdb-fence", "token-3", null), 0, "a routed row is not re-published"); + + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); +}); + +test("SHADOW_QUARANTINE is writable — the CHECK allows it", { skip }, async () => { + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); + await seed("claimdb-quar", { state: "SHADOW_QUARANTINE", stateReason: "no-v1-evidence" }); + const row = await db!.receiptIntake.findUnique({ where: { id: "claimdb-quar" } }); + assert.equal(row?.state, "SHADOW_QUARANTINE"); + await db!.receiptIntake.deleteMany({ where: { sourceRef: { startsWith: PREFIX } } }); +}); + +test("the PER-OBJECT lock really is mutually exclusive, and really is per path", { skip }, async () => { + // The lock that stops the storage-cleanup sweep deleting an object a + // publish has sealed but not yet committed. The unit suites drive it + // through a fake, so this is the only place the SQL itself is exercised: + // that `hashtext('receipt-object:' || $1)` is a legal argument to + // pg_advisory_xact_lock (hashtext returns int4, which has to widen to the + // bigint overload), that $executeRaw is the right verb for a void return, + // and that two concurrent transactions on the same path actually serialize. + // A DIFFERENT path does not block: the try-variant succeeds while the + // first transaction still holds its own lock. + const other = await db!.$transaction(async tx => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('receipt-object:' || ${"receipts/a/v1/aa.png"}))`; + const [row] = await tx.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtext('receipt-object:' || ${"receipts/b/v1/bb.png"})) AS locked`, + ); + return row.locked; + }); + assert.equal(other, true, "two different object paths never wait on each other"); + + // The SAME path does: the second attempt is refused while the first + // transaction holds it, and granted once that transaction has ended. + const same = await db!.$transaction(async tx => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('receipt-object:' || ${"receipts/a/v1/aa.png"}))`; + // A SEPARATE connection, so this is a real contender rather than the + // same transaction re-entering its own lock (which always succeeds). + const [row] = await db!.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtext('receipt-object:' || ${"receipts/a/v1/aa.png"})) AS locked`, + ); + return row.locked; + }); + assert.equal(same, false, "a second holder of the SAME path is made to wait"); + + // ...and the lock is transaction scoped, so it is gone now. pgbouncer's + // transaction pooling is why it has to be: a session lock would be taken on + // a connection handed straight to somebody else, and released never. + const [after] = await db!.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtext('receipt-object:' || ${"receipts/a/v1/aa.png"})) AS locked`, + ); + assert.equal(after.locked, true, "the lock was released when its transaction ended"); +}); + +// ── The importer-wins race, against a REAL Postgres (round-12 item 2) ─────── +// +// The expected crash gap: the worker creates the QBO Purchase, dies before its +// commit, and `syncQboExpenses` imports that Purchase before the retry comes +// round. The retry then finds an Expense it did not write — right about the +// money, silent about this receipt, because `QboExpenseWrite` carries neither +// `costCodeId` nor `receiptUrl`. +// +// The unit suite drives `reconcileExistingExpense` directly. What it cannot +// show is that the two writers actually take the SAME advisory lock, and that +// the reconcile survives a real `Decimal` column and a real `@db.Timestamptz` +// round trip — a `Number(Decimal)` that lost a cent, or a date that came back +// in a different anchor, would park every imported receipt in production while +// every mock stayed green. + +const EXPENSE_PREFIX = "QBTEST-"; + +async function cleanupExpenses() { + await db!.expense.deleteMany({ where: { qbPurchaseId: { startsWith: EXPENSE_PREFIX } } }); + await db!.estimate.deleteMany({ where: { code: { startsWith: EXPENSE_PREFIX } } }); +} + +/** + * A disposable Estimate to hang the Expense off. CREATED, never found: CI's + * database is built from migrations with no seed data, so a test that skipped + * when it could not find one would be a silent no-op in the one place it is + * meant to run. Estimate needs no foreign key of its own. + */ +async function disposableEstimateId(): Promise { + const estimate = await db!.estimate.create({ + data: { + title: "receipt-intake expense reconcile", + code: `${EXPENSE_PREFIX}EST`, + totalAmount: new Prisma.Decimal("0"), + balanceDue: new Prisma.Decimal("0"), + }, + select: { id: true }, + }); + return estimate.id; +} + +test("the per-Purchase lock is SHARED, and it really serializes", { skip }, async () => { + // lockQboExpense is exported from qbo-expense-sync and called by book.ts — + // one function, so the two writers cannot drift onto different keys. This + // proves the SQL runs and that the key actually excludes a second holder. + const purchaseId = `${EXPENSE_PREFIX}lock-1`; + const blocked = await db!.$transaction(async tx => { + await lockQboExpense(tx, purchaseId); + const [row] = await db!.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${purchaseId}, 0)) AS locked`, + ); + return row.locked; + }); + assert.equal(blocked, false, "a second writer of the same Purchase id waits"); + + // A DIFFERENT Purchase id never waits — the lock is per-Purchase, not global. + const other = await db!.$transaction(async tx => { + await lockQboExpense(tx, purchaseId); + const [row] = await db!.$queryRaw<{ locked: boolean }[]>( + Prisma.sql`SELECT pg_try_advisory_xact_lock(hashtextextended(${`${EXPENSE_PREFIX}lock-2`}, 0)) AS locked`, + ); + return row.locked; + }); + assert.equal(other, true); +}); + +test("IMPORTER WINS: the retry reconciles a real imported Expense", { skip }, async () => { + await cleanupExpenses(); + const estimateId = await disposableEstimateId(); + const qbPurchaseId = `${EXPENSE_PREFIX}race-1`; + try { + // The sync imports the Purchase first. Exactly what upsertQboExpense + // writes — note the UTC-midnight date anchor and the absent cost code. + const imported = await db!.expense.create({ + data: { + estimateId, + qbPurchaseId, + qbSyncToken: "0", + amount: new Prisma.Decimal("364.98"), + vendor: "Lowes", + date: new Date("2026-08-03T00:00:00.000Z"), + description: "[QBO] Lowes", + status: "Reviewed", + }, + select: { + id: true, estimateId: true, amount: true, vendor: true, + date: true, costCodeId: true, receiptUrl: true, + }, + }); + + // The worker retry reads it back through the SAME select book.ts uses, + // and reconciles against the receipt's canonical values. + const verdict = reconcileExistingExpense(imported, { + estimateId, + amountCents: 36498, + vendor: "Lowes", + // The worker's own anchor: local midnight, hours away from the + // importer's UTC marker for the same day. + date: new Date("2026-08-03T07:00:00.000Z"), + calendarDay: "2026-08-03", + timeZone: "America/Los_Angeles", + costCodeId: null, + receiptUrl: "https://drive.google.com/file/d/FILE123/view", + }); + + // A real Decimal and a real Timestamptz round trip, and the two date + // anchors, are all non-conflicts. + assert.deepEqual(verdict.conflicts, [], "the imported row is not a conflict"); + assert.deepEqual( + Object.keys(verdict.fill), + ["receiptUrl"], + "only the attribution the importer could not write", + ); + + await db!.expense.update({ where: { id: imported.id }, data: verdict.fill }); + const healed = await db!.expense.findUnique({ + where: { id: imported.id }, + select: { receiptUrl: true, amount: true, vendor: true }, + }); + assert.match(healed!.receiptUrl!, /FILE123/); + assert.equal(Number(healed!.amount), 364.98, "and the money was not touched"); + + // THE CONFLICTING VARIANT. A populated amount that disagrees is a real + // contradiction about real money — it parks rather than linking. + const conflicting = reconcileExistingExpense( + { ...imported, amount: new Prisma.Decimal("401.11") }, + { + estimateId, + amountCents: 36498, + vendor: "Lowes", + date: new Date("2026-08-03T07:00:00.000Z"), + calendarDay: "2026-08-03", + timeZone: "America/Los_Angeles", + costCodeId: null, + receiptUrl: "https://drive.google.com/file/d/FILE123/view", + }, + ); + assert.deepEqual(conflicting.conflicts, ["amount"]); + } finally { + await cleanupExpenses(); + } +}); + +// ── No storage call holds a connection (Codex round-17 item 3) ──────────── +// +// The advisory-lock scheme held an interactive transaction — and therefore a +// pooled connection — across the Supabase seal and the Supabase delete. The +// round-16 deadline caps a storage call at fifteen seconds, so a handful of +// concurrent finalizations exhausted the five-connection pool and later +// requests could not reach the database at all, including to release what they +// had claimed. The lock made the POOL the contended resource, not the object. +// +// Measured against a REAL Postgres, because "how many connections are held +// open in a transaction" is exactly the fact a mocked transaction cannot +// answer. +// +// THE TRANSACTION HELPER IS BUILT OVER THIS FILE'S OWN CLIENT, not imported +// from storage-cleanup. The shipped `inShortTx` goes through the app's prisma +// singleton, which REFUSES a DATABASE_URL without `pgbouncer=true` (see +// buildPrismaClient) — a rule that is right for production and makes the +// singleton unusable against CI's plain Postgres. What is under test here is +// the PROTOCOL: that no transaction is open while the external call runs. The +// shipped helper's own options are pinned separately, by the source tripwire +// in receipt-intake-lease-fence.test.ts. +const shortTx = (body: (tx: unknown) => Promise): Promise => + db!.$transaction(tx => body(tx), { maxWait: 5_000, timeout: 5_000 }) as Promise; + +/** + * Connections held OPEN INSIDE A TRANSACTION right now, other than this query's + * own. `idle in transaction` is the exact state a lock-held connection sits in + * while its body awaits something external. + */ +async function heldTxConnections(): Promise { + const rows = await db!.$queryRaw<{ n: bigint }[]>( + Prisma.sql`SELECT count(*)::bigint AS n + FROM pg_stat_activity + WHERE datname = current_database() + AND state = 'idle in transaction' + AND pid <> pg_backend_pid()`, + ); + return Number(rows[0].n); +} + +test("a SLOW storage call holds ZERO transactions open", { skip }, async () => { + // The publish protocol against the real database, with a deliberately slow + // "storage" call in phase B. Nothing may be held while it runs. + const baseline = await heldTxConnections(); + let heldDuringSeal = -1; + let sealMs = 0; + + const outcome = await sealAndPublish("receipts/intake/probe.png", "probe-row", 1, { + mimeType: "image/png", + fileSize: 4, + fileSha256: "c".repeat(64), + bytes: Buffer.from("abcd"), + }, { + inShortTx: shortTx, + claimCanonicalPath: async () => "intent-probe", + seal: async (_upload: string, canonical: string) => { + const started = Date.now(); + // Long enough that a held transaction would be unmissable, short + // enough not to slow the suite. + await new Promise(resolve => setTimeout(resolve, 750)); + heldDuringSeal = await heldTxConnections(); + sealMs = Date.now() - started; + return canonical; + }, + commit: async () => 1, + queueUploadCleanup: async () => "ev-probe", + resolveCanonicalIntent: async () => {}, + settleUploadCleanup: async () => {}, + } as never, undefined); + + assert.equal(outcome?.published, true, "the publish completed"); + assert.ok(sealMs >= 700, `the seal really was slow (${sealMs}ms)`); + assert.ok( + heldDuringSeal <= baseline, + `no transaction was held while storage ran (baseline ${baseline}, during ${heldDuringSeal})`, + ); +}); + +test("CONTROL: an advisory-lock transaction DOES hold one — the pre-fix shape", { skip }, async () => { + // The same measurement against the scheme this replaced. Without it, the + // assertion above could pass because the probe cannot see anything at all. + const baseline = await heldTxConnections(); + let heldDuringBody = -1; + await db!.$transaction(async tx => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('receipt-object:probe'))`; + await new Promise(resolve => setTimeout(resolve, 750)); + heldDuringBody = await heldTxConnections(); + }, { maxWait: 5_000, timeout: 20_000 }); + + assert.ok( + heldDuringBody > baseline, + `the old scheme held a connection for the whole body (baseline ${baseline}, during ${heldDuringBody})`, + ); +}); + +const SEAL_MS = 400; +const PUBLISHERS = [1, 2, 3, 4, 5, 6]; + +/** + * Run the six publishes, reporting how many seals were in flight at once and + * how long the whole batch took. + * + * MEASURED BY OVERLAP, not by counting connections. An earlier version sampled + * `pg_stat_activity` from inside each seal and demanded zero transactions open + * anywhere. That is not the property: six INDEPENDENT publishers each run their + * own short phase-A and phase-C transactions, and one of those legitimately sits + * `idle in transaction` for the instant between its two statements while another + * publisher happens to be sampling. It failed CI on a peak of one. Whether a + * transaction spans a storage call is proven, for a single publisher with nobody + * else to confuse the count, by the test above; what THIS test is for is the + * consequence the finding named — that concurrent finalizations no longer + * serialize on each other. + */ +async function runBatch( + wrap: (n: number, body: () => Promise) => Promise = (_n, body) => body(), +) { + let inFlight = 0; + let peakInFlight = 0; + const publishOne = (n: number) => sealAndPublish(`receipts/intake/p${n}.png`, `probe-${n}`, 1, { + mimeType: "image/png", + fileSize: 4, + fileSha256: String(n).repeat(64).slice(0, 64), + bytes: Buffer.from("abcd"), + }, { + inShortTx: shortTx, + claimCanonicalPath: async () => `intent-${n}`, + seal: async (_u: string, canonical: string) => { + await wrap(n, async () => { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, SEAL_MS)); + inFlight -= 1; + }); + return canonical; + }, + commit: async () => 1, + queueUploadCleanup: async () => `ev-${n}`, + resolveCanonicalIntent: async () => {}, + settleUploadCleanup: async () => {}, + } as never, undefined); + + const startedAt = Date.now(); + const results = await Promise.all(PUBLISHERS.map(publishOne)); + return { results, peakInFlight, elapsed: Date.now() - startedAt }; +} + +test("CONCURRENT publishes do not queue behind each other on the pool", { skip }, async () => { + const { results, peakInFlight, elapsed } = await runBatch(); + + assert.ok(results.every(r => r?.published), "all six published"); + assert.equal(peakInFlight, PUBLISHERS.length, `all six seals overlapped (peak ${peakInFlight})`); + // Serialized, this batch could not finish in less than six seals. The bound + // is loose on purpose — the claim is "they overlap", not a latency budget. + assert.ok( + elapsed < SEAL_MS * PUBLISHERS.length, + `finished in ${elapsed}ms, less than ${SEAL_MS * PUBLISHERS.length}ms of queued seals`, + ); + assert.ok(elapsed >= SEAL_MS, `the seals really ran (${elapsed}ms)`); +}); + +test("CONTROL: serialize the same six and both measurements move", { skip }, async () => { + // The pre-fix shape, without asserting anything about how it was + // serialized: one at a time is one at a time. Without this, a batch that + // silently did no work at all would satisfy the test above. + let chain: Promise = Promise.resolve(); + const oneAtATime = (_n: number, body: () => Promise) => { + const next = chain.then(body); + chain = next.catch(() => {}); + return next; + }; + + const { results, peakInFlight, elapsed } = await runBatch(oneAtATime); + + assert.ok(results.every(r => r?.published), "they all still publish, just not together"); + assert.equal(peakInFlight, 1, "one seal at a time — what the advisory lock produced"); + assert.ok( + // A timer may fire a millisecond early; the claim is the shape, not the ms. + elapsed >= SEAL_MS * PUBLISHERS.length - 50, + `and the batch takes the full queue (${elapsed}ms)`, + ); +}); + +// ── The old state default is REPAIRED, against real Postgres (round-18 #4) ── +// +// A ReceiptIntake created by an earlier Phase-1 revision carries +// DEFAULT 'RECEIVED'. `CREATE TABLE IF NOT EXISTS` is a no-op on it and adding +// columns cannot change a default, so every row inserted without an explicit +// state skipped STAGING and was claimable by the worker before its object +// existed. The verify reported clean because it read column NAMES. + +const DEFAULT_PROBE = "ReceiptIntakeDefaultProbe"; + +async function probeDefault(): Promise { + const rows = await db!.$queryRawUnsafe<{ column_default: string | null }[]>( + `SELECT column_default FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1 AND column_name='state'`, + DEFAULT_PROBE, + ); + return rows[0]?.column_default ?? null; +} + +test("apply REPAIRS a table created with the old default", { skip }, async () => { + // A stand-in table in the shape the earlier revision left behind. Using a + // probe table rather than the real one keeps this test from depending on + // (or damaging) whatever state the migrations job built. + await db!.$executeRawUnsafe(`DROP TABLE IF EXISTS "${DEFAULT_PROBE}"`); + try { + await db!.$executeRawUnsafe( + `CREATE TABLE "${DEFAULT_PROBE}" ("id" TEXT PRIMARY KEY, "state" TEXT NOT NULL DEFAULT 'RECEIVED')`, + ); + assert.match(String(await probeDefault()), /RECEIVED/, "the drifted shape, as an earlier run left it"); + + // PRE-FIX CONTROL: a verify that reads column NAMES reports clean while + // the default is wrong — which is exactly how this survived. + const names = await db!.$queryRawUnsafe<{ column_name: string }[]>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema='public' AND table_name=$1`, + DEFAULT_PROBE, + ); + assert.ok(names.some(r => r.column_name === "state"), "the column is present in both shapes"); + + // ...and the default check catches it. + // + // ROUTED BY ARGUMENT, not by rewriting the SQL: verifyColumnDefaults + // passes the table name as $1, so a `sql.replace("ReceiptIntake", ...)` + // shim substitutes nothing and the query silently runs against the REAL + // table — which, correctly migrated, reports clean and hides the probe. + const routed: unknown[][] = []; + const probeQuery = (sql: string, ...args: unknown[]) => { + const swapped = args.map(a => (a === "ReceiptIntake" ? DEFAULT_PROBE : a)); + routed.push(swapped); + return db!.$queryRawUnsafe(sql, ...swapped); + }; + + const before = await verifyColumnDefaults(probeQuery); + assert.ok( + routed.length > 0 && routed.every(a => a.includes(DEFAULT_PROBE)), + `the verify was pointed at the probe table: ${JSON.stringify(routed)}`, + ); + assert.equal(before.problems.length, 1, JSON.stringify(before)); + + // THE REPAIR — the same statement the apply script and the migration + // both carry, run here against the drifted table. + await db!.$executeRawUnsafe( + `ALTER TABLE "${DEFAULT_PROBE}" ALTER COLUMN "state" SET DEFAULT 'STAGING'`, + ); + assert.match(String(await probeDefault()), /STAGING/, "repaired"); + + const after = await verifyColumnDefaults(probeQuery); + assert.deepEqual(after.problems, [], "and the verify is clean"); + + // Idempotent: running it again changes nothing. + await db!.$executeRawUnsafe( + `ALTER TABLE "${DEFAULT_PROBE}" ALTER COLUMN "state" SET DEFAULT 'STAGING'`, + ); + assert.match(String(await probeDefault()), /STAGING/); + + // And a row inserted without a state now lands in STAGING — invisible + // to the worker's claim until its object is published. + await db!.$executeRawUnsafe(`INSERT INTO "${DEFAULT_PROBE}" ("id") VALUES ('probe-1')`); + const inserted = await db!.$queryRawUnsafe<{ state: string }[]>( + `SELECT state FROM "${DEFAULT_PROBE}" WHERE id='probe-1'`, + ); + assert.equal(inserted[0].state, "STAGING"); + } finally { + await db!.$executeRawUnsafe(`DROP TABLE IF EXISTS "${DEFAULT_PROBE}"`); + } +}); + +test("the REAL ReceiptIntake ends up with the STAGING default", { skip }, async () => { + // The migrations job builds this table from prisma/migrations, so this is + // the end-to-end assertion that the upgrade path carries the repair. + const rows = await db!.$queryRawUnsafe<{ column_default: string | null }[]>( + `SELECT column_default FROM information_schema.columns + WHERE table_schema='public' AND table_name='ReceiptIntake' AND column_name='state'`, + ); + assert.equal(rows.length, 1, "the table exists"); + assert.match(String(rows[0].column_default), /STAGING/, `saw ${rows[0].column_default}`); +}); + +// ── The upgrade runs the SHIPPED statement list, on a PRE-EXISTING table ── +// +// The test above proves the repair statement works. It does not prove the +// statement the script actually ships is in the list, or that it is in the +// ADDITIVE section where an already-created table can still be reached by it +// -- and a repair that only ever runs inside `CREATE TABLE IF NOT EXISTS` is +// exactly the bug, because that CREATE is a no-op on the drifted table. +// +// So this one builds the OLD shape in a schema of its own and runs every +// statement from `statements` over it, unchanged. `search_path` puts the +// probe schema first and public second, so the unqualified names in those +// statements resolve to the probe's table while the foreign keys still find +// the real Project / CostCode / User. One interactive transaction, so every +// statement shares the connection the SET LOCAL applies to. + +const UPGRADE_SCHEMA = "receipt_intake_upgrade_probe"; + +/** The table as an EARLIER Phase-1 revision left it: old default, missing columns. */ +const DRIFTED_TABLE = `CREATE TABLE "ReceiptIntake" ( + "id" TEXT NOT NULL, + "source" TEXT NOT NULL, + "sourceRef" TEXT NOT NULL, + "state" TEXT NOT NULL DEFAULT 'RECEIVED', + "dryRun" BOOLEAN NOT NULL DEFAULT true, + "stateReason" TEXT, + "projectId" TEXT, + "costCodeId" TEXT, + "suggestedCostCodeId" TEXT, + "suggestedConfidence" DOUBLE PRECISION, + "createdById" TEXT, + "storagePath" TEXT NOT NULL, + "fileName" TEXT, + "mimeType" TEXT NOT NULL, + "fileSize" INTEGER NOT NULL, + "fileSha256" TEXT NOT NULL, + "vendor" TEXT, + "txnDate" DATE, + "totalCents" INTEGER, + "taxCents" INTEGER, + "docType" TEXT, + "refNumber" TEXT, + "memo" TEXT, + "readJson" TEXT, + "readAt" TIMESTAMP(3), + "dedupStrongKey" TEXT, + "dedupWeakKey" TEXT, + "duplicateOfId" TEXT, + "qbPurchaseId" TEXT, + "expenseId" TEXT, + "archiveDriveFileId" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "lastError" TEXT, + "nextRetryAt" TIMESTAMP(3), + "bookedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "ReceiptIntake_pkey" PRIMARY KEY ("id") + )`; + +async function defaultIn(schema: string): Promise { + const rows = await db!.$queryRawUnsafe<{ column_default: string | null }[]>( + `SELECT column_default FROM information_schema.columns + WHERE table_schema=$1 AND table_name='ReceiptIntake' AND column_name='state'`, + schema, + ); + return rows[0]?.column_default ?? null; +} + +test("the SHIPPED statements upgrade a table created with the old default", { skip }, async () => { + await db!.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${UPGRADE_SCHEMA}" CASCADE`); + try { + await db!.$executeRawUnsafe(`CREATE SCHEMA "${UPGRADE_SCHEMA}"`); + await db!.$transaction(async tx => { + await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${UPGRADE_SCHEMA}", public`); + await tx.$executeRawUnsafe(DRIFTED_TABLE); + }, { timeout: 30_000 }); + + assert.match( + String(await defaultIn(UPGRADE_SCHEMA)), + /RECEIVED/, + "the drifted shape, as an earlier revision left it", + ); + + // EVERY statement the script ships, in order, unchanged. + await db!.$transaction(async tx => { + await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${UPGRADE_SCHEMA}", public`); + for (const sql of statements as string[]) await tx.$executeRawUnsafe(sql); + }, { timeout: 60_000 }); + + assert.match( + String(await defaultIn(UPGRADE_SCHEMA)), + /STAGING/, + "the upgrade section reached a table CREATE TABLE IF NOT EXISTS could not", + ); + + // The columns the additive section adds are there too, so this really + // was an upgrade of the old table and not a fresh create. + const columns = await db!.$queryRawUnsafe<{ column_name: string }[]>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema=$1 AND table_name='ReceiptIntake'`, + UPGRADE_SCHEMA, + ); + const names = new Set(columns.map(c => c.column_name)); + for (const added of ["busyPasses", "uploadLeaseNonce", "claimToken", "expectedSha256"]) { + assert.ok(names.has(added), `the additive section added ${added}`); + } + + // And a row inserted with no state now lands in STAGING -- invisible to + // the worker's claim until its object is published, which is the whole + // point of the default. + await db!.$transaction(async tx => { + await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${UPGRADE_SCHEMA}", public`); + await tx.$executeRawUnsafe( + `INSERT INTO "ReceiptIntake" + ("id", "source", "sourceRef", "storagePath", "mimeType", "fileSize", "fileSha256", "updatedAt") + VALUES ('probe-1', 'drive', 'drive:probe-1', 'receipts/intake/probe-1.png', + 'image/png', 4, 'b', NOW())`, + ); + }, { timeout: 30_000 }); + const inserted = await db!.$queryRawUnsafe<{ state: string }[]>( + `SELECT state FROM "${UPGRADE_SCHEMA}"."ReceiptIntake" WHERE id='probe-1'`, + ); + assert.equal(inserted[0].state, "STAGING"); + + // IDEMPOTENT: the whole list again changes nothing. + await db!.$transaction(async tx => { + await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${UPGRADE_SCHEMA}", public`); + for (const sql of statements as string[]) await tx.$executeRawUnsafe(sql); + }, { timeout: 60_000 }); + assert.match(String(await defaultIn(UPGRADE_SCHEMA)), /STAGING/); + } finally { + await db!.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${UPGRADE_SCHEMA}" CASCADE`); + } +}); + +test("CONTROL: the drifted table WITHOUT the upgrade keeps the old default", { skip }, async () => { + // Without this, a statement list that happened to CREATE a fresh table + // would satisfy the test above while never repairing anything. + await db!.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${UPGRADE_SCHEMA}_ctl" CASCADE`); + try { + await db!.$executeRawUnsafe(`CREATE SCHEMA "${UPGRADE_SCHEMA}_ctl"`); + await db!.$transaction(async tx => { + await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${UPGRADE_SCHEMA}_ctl", public`); + await tx.$executeRawUnsafe(DRIFTED_TABLE); + // Only the CREATE half of the shipped list -- what a script whose + // repair lived inside CREATE TABLE IF NOT EXISTS would achieve. + // + // Asserted by NAME, not by count: the list grows (ReceiptObjectClaim + // arrived in round 21), and a bare number turns every new table into + // a failure of a test that is not about tables at all. + const createOnly = (statements as string[]).filter(sql => /CREATE TABLE IF NOT EXISTS/.test(sql)); + assert.ok( + createOnly.some(sql => sql.includes('"ReceiptIntake"')), + "the shipped list still creates the table this control is about", + ); + for (const sql of createOnly) await tx.$executeRawUnsafe(sql); + }, { timeout: 30_000 }); + + assert.match( + String(await defaultIn(`${UPGRADE_SCHEMA}_ctl`)), + /RECEIVED/, + "a no-op CREATE cannot change a default -- which is why the ALTER exists", + ); + } finally { + await db!.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${UPGRADE_SCHEMA}_ctl" CASCADE`); + } +}); + +// ── PUBLISHER vs SWEEPER: exactly one takes the path (round-21 finding 1) ── +// +// The write skew, precisely: an EXPIRED provisional intent exists. The sweeper +// reads it, finds the claim lapsed, and UPDATES that event into a deleting +// claim. The publisher reads the same events, also finds nothing live, and +// INSERTS a fresh publishing claim -- and its reclamation only touches +// `pending` rows, so it does not even see the provisional one. Two +// transactions, two different rows, no conflict at READ COMMITTED: both +// commit, and the sweeper then deletes the object the publisher has sealed but +// not yet pointed at. +// +// Both claim transactions now take the same per-path advisory lock as their +// FIRST statement, and the claim itself lives in a table whose primary key is +// the path. This is the only place either can be shown to work: a mock cannot +// block, and a single connection cannot interleave. + +const RACE_PATH = `receipts/intake/${PREFIX}race/v1/abc.png`; + +async function clearClaims() { + await db!.receiptObjectClaim.deleteMany({ where: { storagePath: { contains: PREFIX } } }); +} + +/** + * Take a claim on `client`, then HOLD the transaction open until `release` + * resolves. The hold is what lets the other side try while this one still has + * the lock. + */ +function claimHolding( + client: PrismaClient, + kind: "publishing" | "deleting", + release: Promise, + now: Date, +) { + return client.$transaction(async tx => { + const got = await acquireObjectClaim( + tx as unknown as Prisma.TransactionClient, + RACE_PATH, + kind, + new Date(now.getTime() + 60_000), + now, + ); + await release; + return got; + }, { maxWait: 20_000, timeout: 20_000 }); +} + +const settle = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +for (const [firstKind, secondKind] of [ + ["deleting", "publishing"], + ["publishing", "deleting"], +] as const) { + test( + `CLAIM RACE (${firstKind} first): exactly one wins, and the loser sees it`, + { skip }, + async () => { + await clearClaims(); + const now = new Date(); + + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + + // The first claimant takes the lock and keeps its transaction open. + const first = claimHolding(dbA!, firstKind, held, now); + await settle(300); + + // The second starts while that lock is held. It BLOCKS -- which is the + // whole point; before the lock it would have read the same state and + // written its own claim into a different row. + const second = claimHolding(dbB!, secondKind, Promise.resolve(), now); + await settle(300); + + let secondSettled = false; + void second.then(() => { secondSettled = true; }, () => { secondSettled = true; }); + await settle(100); + assert.equal(secondSettled, false, `the ${secondKind} claim waits for the lock`); + + release(); + const [a, b] = await Promise.all([first, second]); + + assert.equal(a.ok, true, `the ${firstKind} claim won`); + assert.equal(b.ok, false, `and the ${secondKind} claim was refused`); + assert.equal((b as { heldBy: string }).heldBy, firstKind, "by name"); + + // ONE row, one live claim -- the primary key says so. + const rows = await db!.receiptObjectClaim.findMany({ + where: { storagePath: RACE_PATH }, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, firstKind); + assert.equal(rows[0].token, (a as { token: string }).token); + }, + ); +} + +test("CONTROL: two publishers may share a path, because the bytes are identical", { skip }, async () => { + // Without this, a claim that refused everything would pass the two tests + // above while breaking every concurrent publish -- which the whole + // content-addressed design depends on being allowed. + await clearClaims(); + const now = new Date(); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + + const first = claimHolding(dbA!, "publishing", held, now); + await settle(300); + const second = claimHolding(dbB!, "publishing", Promise.resolve(), now); + await settle(200); + release(); + const [a, b] = await Promise.all([first, second]); + + assert.equal(a.ok, true); + assert.equal(b.ok, true, "a second publisher is not a conflict"); + const rows = await db!.receiptObjectClaim.findMany({ where: { storagePath: RACE_PATH } }); + assert.equal(rows.length, 1, "still one row: the path is the primary key"); +}); + +test("PRE-FIX CONTROL: without the lock, both claimants commit", { skip }, async () => { + // The shipped shape, reproduced against the same real Postgres: two + // transactions that read the claim state and then write DIFFERENT rows. No + // conflict is possible at READ COMMITTED, so both succeed -- and that is + // precisely how a sweeper came to delete an object a publisher had sealed. + await clearClaims(); + const eventPrefix = `${PREFIX}skew`; + await db!.automationEvent.deleteMany({ where: { detail: { contains: eventPrefix } } }); + const expired = await db!.automationEvent.create({ + data: { + kind: "receipt-intake-storage-cleanup", + status: "provisional", + reason: "canonical-seal-intent", + source: "receipt-intake", + detail: JSON.stringify({ + storagePath: `${eventPrefix}/object.png`, + claimToken: "long-dead", + claimKind: "publishing", + claimUntil: new Date(Date.now() - 60_000).toISOString(), + }), + }, + }); + + // The sweeper's shape: UPDATE the expired event into a deleting claim. + const sweeper = dbA!.$transaction(async tx => { + const seen = await tx.automationEvent.findMany({ + where: { detail: { contains: eventPrefix } }, + select: { id: true, detail: true }, + }); + await settle(300); + await tx.automationEvent.update({ + where: { id: expired.id }, + data: { reason: "swept-claim" }, + }); + return seen.length; + }, { maxWait: 20_000, timeout: 20_000 }); + + // The publisher's shape: INSERT a fresh provisional claim. + const publisher = dbB!.$transaction(async tx => { + const seen = await tx.automationEvent.findMany({ + where: { detail: { contains: eventPrefix } }, + select: { id: true, detail: true }, + }); + await settle(300); + const made = await tx.automationEvent.create({ + data: { + kind: "receipt-intake-storage-cleanup", + status: "provisional", + reason: "publisher-claim", + source: "receipt-intake", + detail: JSON.stringify({ storagePath: `${eventPrefix}/object.png` }), + }, + select: { id: true }, + }); + return { seen: seen.length, made: made.id }; + }, { maxWait: 20_000, timeout: 20_000 }); + + const [swept, published] = await Promise.all([sweeper, publisher]); + + assert.equal(swept, 1, "the sweeper saw only the expired intent"); + assert.equal(published.seen, 1, "and so did the publisher: neither saw the other"); + assert.ok(published.made, "BOTH committed -- different rows, no conflict, no lock"); + + await db!.automationEvent.deleteMany({ where: { detail: { contains: eventPrefix } } }); +}); + +// -- A DELETE MAY NOT OUTLIVE ITS CLAIM (round-22, real Postgres) --------- +// +// The sweep took its claim with an expiry derived from the instant the PASS +// opened. A late item reached the delete tens of seconds later and then spent +// up to STORAGE_CALL_MAX_MS inside the removal itself, so the claim could +// lapse mid-flight -- and a publisher that took the path in that window had +// its freshly sealed object deleted by a call that was already in the air. +// +// The renewal is what closes it, and it can only be shown against a real +// database: the takeover it prevents is another connection's transaction. + +test("a publisher CANNOT take a path whose delete renewed its claim", { skip }, async () => { + await clearClaims(); + const opened = new Date(); + + // The sweeper claims with a SHORT lease, as a pass that opened long ago + // effectively has. + const shortLease = new Date(opened.getTime() + 1_000); + const taken = await dbA!.$transaction(async tx => acquireObjectClaim( + tx as unknown as Prisma.TransactionClient, + RACE_PATH, + "deleting", + shortLease, + opened, + ), { maxWait: 20_000, timeout: 20_000 }); + assert.equal(taken.ok, true); + const token = (taken as { token: string }).token; + + // ...and RENEWS it immediately before the delete, from CURRENT time. + const now = new Date(); + const renewed = await dbA!.$transaction(async tx => renewObjectClaim( + tx as unknown as Prisma.TransactionClient, + RACE_PATH, + "deleting", + token, + new Date(now.getTime() + DELETE_CLAIM_LEASE_MS), + now, + ), { maxWait: 20_000, timeout: 20_000 }); + assert.equal(renewed, true, "the renewal took"); + + // The delete is now notionally in flight. Past the ORIGINAL expiry, a + // publisher tries to take over -- and is refused, because the claim it + // would have inherited was extended to cover the call still running. + await settle(1_200); + assert.ok(new Date() > shortLease, "we are past the claim's original expiry"); + + const publisher = await dbB!.$transaction(async tx => acquireObjectClaim( + tx as unknown as Prisma.TransactionClient, + RACE_PATH, + "publishing", + new Date(Date.now() + 60_000), + new Date(), + ), { maxWait: 20_000, timeout: 20_000 }); + + assert.equal(publisher.ok, false, "the delete still holds the path"); + assert.equal((publisher as { heldBy: string }).heldBy, "deleting"); + + const held = await db!.receiptObjectClaim.findUnique({ where: { storagePath: RACE_PATH } }); + assert.equal(held?.token, token, "and it is still the deleter's own claim"); + assert.ok( + held!.expiresAt.getTime() - now.getTime() >= STORAGE_CALL_MAX_MS, + "renewed for longer than a delete can take", + ); +}); + +test("PRE-FIX CONTROL: WITHOUT the renewal the publisher takes the path", { skip }, async () => { + // The same interleaving with the claim left on its original expiry -- + // which is what a claim measured from the pass's opening instant amounts + // to by the time a late item reaches its delete. + await clearClaims(); + const opened = new Date(); + const shortLease = new Date(opened.getTime() + 1_000); + + const taken = await dbA!.$transaction(async tx => acquireObjectClaim( + tx as unknown as Prisma.TransactionClient, + RACE_PATH, + "deleting", + shortLease, + opened, + ), { maxWait: 20_000, timeout: 20_000 }); + assert.equal(taken.ok, true); + + // No renewal. The delete is in flight; the claim lapses under it. + await settle(1_200); + + const publisher = await dbB!.$transaction(async tx => acquireObjectClaim( + tx as unknown as Prisma.TransactionClient, + RACE_PATH, + "publishing", + new Date(Date.now() + 60_000), + new Date(), + ), { maxWait: 20_000, timeout: 20_000 }); + + assert.equal( + publisher.ok, + true, + "the publisher takes the path out from under a delete that is still running", + ); + const held = await db!.receiptObjectClaim.findUnique({ where: { storagePath: RACE_PATH } }); + assert.equal(held?.kind, "publishing", "and its seal is what the in-flight delete would remove"); +}); diff --git a/tests/receipt-intake-claim-release.test.ts b/tests/receipt-intake-claim-release.test.ts new file mode 100644 index 000000000..f708b8959 --- /dev/null +++ b/tests/receipt-intake-claim-release.test.ts @@ -0,0 +1,156 @@ +/** + * Ownership release. + * + * The worker claims a row by writing a claim token, and every write it makes + * afterwards is fenced on {id, state, claimToken}. That fence is only half the + * mechanism: a transition that COMPLETES the work must also hand the row back, + * in the SAME write. Leave the token behind and the row is owned by a pass that + * has finished — the next pass's CAS matches nothing, no other write can move + * it, and it sits until a human notices. (The claim query skips rows that carry + * a live token, which is what makes the leak permanent rather than a delay.) + * + * This walks every UPDATE in the worker's dependency factory rather than + * naming the ones that exist today, so a transition added later is covered by + * default instead of by remembering to add a case here. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(__dirname, ".."); +const route = readFileSync(path.join(ROOT, "src/app/api/cron/receipt-intake-worker/route.ts"), "utf8"); + +/** Every `data: { ... }` of every receiptIntake update in the file, with context. */ +function updateBlocks(source: string): { data: string; where: string }[] { + const blocks: { data: string; where: string }[] = []; + const needle = "receiptIntake.update"; + let at = source.indexOf(needle); + while (at !== -1) { + // Balanced scan from the call's opening brace to its close. + const open = source.indexOf("{", at); + let depth = 0; + let end = open; + for (; end < source.length; end++) { + if (source[end] === "{") depth++; + else if (source[end] === "}") { depth--; if (depth === 0) break; } + } + const call = source.slice(open, end + 1); + const dataAt = call.indexOf("data: {"); + if (dataAt !== -1) { + let d = 0; + const i = call.indexOf("{", dataAt); + let stop = i; + for (; stop < call.length; stop++) { + if (call[stop] === "{") d++; + else if (call[stop] === "}") { d--; if (d === 0) break; } + } + // Everything before `data:` is the where clause (and, for the + // aliased ones, the `owns` object it was built from). + blocks.push({ data: call.slice(i, stop + 1), where: call.slice(0, dataAt) }); + } + at = source.indexOf(needle, end); + } + return blocks; +} + +/** + * Only the CLAIM HOLDER's writes. The cutover sweep, the STAGING sweep and the + * claim query itself all write rows nobody owns — releasing a claim there is + * meaningless, and the claim query is the one write that TAKES ownership. + */ +const heldSection = route.slice(route.indexOf("applyState: async")); +const blocks = updateBlocks(heldSection); + +test("the walker actually found the worker's updates", () => { + // A structural test that matches nothing passes vacuously forever. + assert.ok(blocks.length >= 7, `expected the worker's updates, found ${blocks.length}`); +}); + +test("EVERY completed, deferred or terminal transition releases the claim", () => { + const leaked: string[] = []; + for (const block of blocks) { + // Shorthand counts: `{ attempts, nextRetryAt }` is every bit as much a + // transition as `{ nextRetryAt: x }`, and a filter that only saw the + // long form let a real leak through when this guard was first written. + const setsState = /\bstate\b/.test(block.data); + const setsRetry = /\bnextRetryAt\b/.test(block.data); + if (!setsState && !setsRetry) continue; // not a transition (e.g. the send mark) + + // THE ONE EXCEPTION: READ -> BOOKING hands the row straight to + // bookReceipt in the same pass, and both its send mark and its BOOKED + // commit CAS on this same token. Releasing here would admit a second + // worker to the same booking. + // + // stateReason is deliberately absent from this write (not set to + // null): a READ row's stateReason can only ever be null or + // "tax-implausible" (finishRouting is the ONLY path to READ), and that + // warning must survive into BOOKING/BOOKED rather than being cleared + // on the way through — see preservedTaxWarning in route-state.ts. + const isPromotion = /state: "BOOKING" \}/.test(block.data) && !setsRetry; + if (isPromotion) continue; + + const releases = /claimToken: null/.test(block.data) || /RELEASE_CLAIM/.test(block.data); + if (!releases) leaked.push(block.data.replace(/\s+/g, " ").slice(0, 120)); + } + assert.deepEqual(leaked, [], "these transitions keep a claim nobody will ever release"); +}); + +test("releasing clears BOTH claim fields, never just the token", () => { + // claimedAt is what the stuck-row health probe reads. A token cleared + // without its timestamp leaves a row that looks claimed to everything + // except the CAS. + assert.match(route, /const RELEASE_CLAIM = \{ claimToken: null, claimedAt: null \}/); + const halfReleased = blocks + .filter(b => /claimToken: null/.test(b.data) && !/claimedAt: null/.test(b.data)) + .map(b => b.data.replace(/\s+/g, " ").slice(0, 90)); + assert.deepEqual(halfReleased, [], "these rows still look claimed to the health probe"); +}); + +test("a routed row keeps no claim: finishRouting clears both fields under its fence", () => { + const fn = route.slice(route.indexOf("finishRouting: async")); + const body = fn.slice(0, fn.indexOf("\n },")); + assert.match(body, /where: \{ id: rowId, state: "RECEIVED", claimToken \}/, "fenced on state AND token"); + assert.match(body, /state: "READ"/); + assert.match(body, /claimToken: null/); + assert.match(body, /claimedAt: null/); +}); + +test("every fenced write CASes on the OWNERSHIP it was handed, not on the id alone", () => { + // `where: { id }` on its own is how a superseded pass overwrites the work + // of the pass that replaced it. + const fenced = blocks.filter(b => /\bstate\b/.test(b.data) || /\bnextRetryAt\b/.test(b.data)); + assert.ok(fenced.length >= 6, `expected the worker's transitions, found ${fenced.length}`); + for (const block of fenced) { + // Either the token is named in the where clause, or it arrives via the + // `owns` alias — which is itself built from {state, claimToken}. + assert.match( + block.where, + /claimToken|where: owns/, + `unfenced write: ${block.data.replace(/\s+/g, " ").slice(0, 90)}`, + ); + } + assert.match(route, /const owns = \{ id: rowId, state: "BOOKING", claimToken \} as const;/); +}); + +test("applyRead is the ONE lease-keeping write, and it can only say RECEIVED", () => { + // The scanner above skips it, because its `data` carries no state literal — + // it spreads a patch. So it is asserted directly instead: routing continues + // under this lease, which is the whole reason it keeps it, and the compiler + // is what stops a TERMINAL state being routed back through it. + const worker = readFileSync( + path.join(__dirname, "..", "src/lib/receipt-intake/worker.ts"), + "utf8", + ); + assert.match(worker, /patch: ReadPatch & \{ state: "RECEIVED" \}/); + + const fn = route.slice(route.indexOf("applyRead: async")); + const body = fn.slice(0, fn.indexOf("findWeakHit:")); + assert.match(body, /where: \{ id: rowId, state: ownership\.state, claimToken: ownership\.claimToken \}/, + "still fenced on ownership like every other write"); + assert.ok(!/RELEASE_CLAIM/.test(body), "and deliberately does NOT release: routing is not finished"); + assert.match(body, /nextRetryAt is deliberately/, "with the reason written down at the write itself"); + + // Every TERMINAL outcome goes through applyState, which does release. + assert.match(worker, /const owned = await deps\.applyState\(row\.id, gate\.state, note\(gate\.stateReason\)/); +}); diff --git a/tests/receipt-intake-cleanup.test.ts b/tests/receipt-intake-cleanup.test.ts new file mode 100644 index 000000000..82a8536dd --- /dev/null +++ b/tests/receipt-intake-cleanup.test.ts @@ -0,0 +1,1767 @@ +/** + * The orphaned-object cleanup queue. + * + * This exists for one failure: a row is deleted while its object may still be + * in the bucket. After that nothing in the database references those bytes, so + * the queue record IS the last pointer to them — which makes "best effort" the + * wrong posture for writing it, and makes deleting the wrong path unrecoverable. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(__dirname, ".."); +const cleanup = readFileSync(path.join(ROOT, "src/lib/receipt-intake/storage-cleanup.ts"), "utf8"); +const intake = readFileSync(path.join(ROOT, "src/app/api/receipts/intake/route.ts"), "utf8"); +const bucket = readFileSync(path.join(ROOT, "src/lib/receipt-intake/bucket.ts"), "utf8"); +const start = readFileSync(path.join(ROOT, "src/app/api/receipts/intake/start/route.ts"), "utf8"); + +/** + * The body of one top-level function, EOL-agnostic. + * + * `indexOf("\n}\n")` returns -1 on a CRLF checkout (the bytes there are + * "\r\n}\r\n"), and `slice(0, -1)` then quietly hands back the REST OF THE + * FILE — so an assertion scoped to one function silently starts reading every + * function after it, and these tests pass or fail for the wrong reason. Git's + * autocrlf makes that a property of who cloned the repo, not of the code. + */ +function bodyOf(source: string, declaration: string): string { + const from = source.indexOf(declaration); + assert.notEqual(from, -1, `not found: ${declaration}`); + const rest = source.slice(from); + const end = rest.search(/\r?\n\}\r?\n/); + assert.notEqual(end, -1, `no closing brace found for ${declaration}`); + return rest.slice(0, end); +} + +test("bodyOf stops at the function it was given, on either line ending", () => { + // The control. Without it the helper could go back to returning the whole + // file and every assertion below would still pass. + const lf = "function a() {\n inA();\n}\n\nfunction b() {\n inB();\n}\n"; + for (const text of [lf, lf.replace(/\n/g, "\r\n")]) { + const body = bodyOf(text, "function a()"); + assert.match(body, /inA\(\)/); + assert.ok(!body.includes("inB()"), "it did not run on into the next function"); + } +}); + +test("recording a cleanup is durable, not fire-and-forget", () => { + // logAutomationEvent never throws by contract, so "it did not throw" is not + // proof it wrote. The record is read back, and the function throws if it is + // not there — the caller has to know. + // It no longer goes through logAutomationEvent at all. That helper swallows + // its insert failure by contract, and the read-back that compensated + // searched for ANY event whose detail CONTAINED this path — so on a retry, + // with an older provisional event for the same canonical path already + // there, a FAILED insert returned that old id and its stale deadline as if + // the write had just landed. + const body = bodyOf(cleanup, "export async function recordPendingCleanup"); + assert.ok(!/\.catch\(\(\)\s*=>\s*\{/.test(body), "the write is not swallowed"); + // A CALL, not a mention: the doc comment names the helper it stopped using. + assert.ok(!/await logAutomationEvent\(/.test(body), "no fire-and-forget writer"); + assert.ok(!/await prisma\.automationEvent\.findFirst\(/.test(body), "nothing is searched for after"); + assert.match(body, /queueObjectCleanup\(client,/, "a throwing create returns the id it wrote"); + // ...and `client` DEFAULTS to prisma, so the injection is a test seam + // and not a way for a caller to write somewhere else. + assert.match(cleanup, /client: CleanupQueueTx = prisma,/); +}); + +test("an unrecordable cleanup KEEPS the row as the last pointer", () => { + // If the queue record cannot be written, deleting the row would orphan the + // bytes with nothing anywhere referencing them. The STAGING row is then the + // only way to find them, so it stays and the sweeper resolves it. + assert.match(intake, /cleanup unrecordable; keeping the row as the pointer/); + assert.match(intake, /retained: true/); +}); + +test("a failed row deletion is surfaced, not swallowed", () => { + // Otherwise the caller retries, hits a sourceRef conflict against a row it + // was just told does not exist, and has no way to interpret that. + assert.match(intake, /row delete failed after an ambiguous upload/); +}); + +test("the cleanup worker refuses to delete a path a LIVE row still points at", () => { + // Reachable through the recovery sequence: an ambiguous upload records a + // cleanup, the row goes, the caller retries, and the retry's row can point + // at the same path — or a seal publishes a canonical path an older pending + // event names. Deleting then destroys a receipt in active use. + const fn = bodyOf(cleanup, "export async function retryPendingCleanups"); + assert.match(fn, /receiptIntake\.findFirst\(\{\s*\n?\s*where: \{ storagePath \}/, "it checks for a referencing row"); + assert.match(fn, /still referenced by/, "and resolves rather than retrying forever"); + // THE CLAIM COMES FIRST NOW, and it is what takes the per-path lock. + // + // The order inverted deliberately in round 21. Reading 'is this path + // free' and writing 'it is mine' have to be one atomic step against every + // other claimant, and acquireObjectClaim takes the advisory lock as its + // first statement -- so it has to BE the first statement. Everything + // below it, including the reference check, is then decided under the + // lock. A `referenced` verdict hands the path straight back. + const claimAt = fn.indexOf('acquireObjectClaim(tx, storagePath, "deleting"'); + const refAt = fn.indexOf("still referenced by"); + const recheckAt = fn.indexOf("const stillOurs = await deps.inShortTx("); + const removeAt = fn.indexOf("await deps.remove(storagePath)"); + assert.ok(claimAt > 0, "the sweep takes a claim"); + assert.ok(claimAt < refAt, "under the lock the claim takes, so the read is serialized too"); + assert.ok(refAt < recheckAt, "the reference check still precedes the delete"); + assert.ok(claimAt < recheckAt, "the claim is written, then re-read"); + // ...and a verdict that deletes nothing releases the path rather than + // holding it for the lease's length. + assert.match(fn, /await releaseObjectClaim\(tx, storagePath, taken\.token\);/); + assert.ok(recheckAt < removeAt, "and only then is anything deleted"); +}); + +test("an event is resolved only AFTER a confirmed deletion", () => { + const fn = bodyOf(cleanup, "export async function retryPendingCleanups"); + // The delete's catch continues to the next event rather than falling + // through to the resolve. + assert.match(fn, /\} catch \{[\s\S]*?continue;/, "a failed delete leaves the event pending"); + assert.ok( + fn.lastIndexOf("removeReceiptObject") < fn.lastIndexOf('status: "resolved" }'), + "resolve happens after the delete", + ); +}); + +test("a missing storage client is an ERROR for the cleanup path", () => { + // A deleter that returns quietly with no client is right for best-effort + // callers and catastrophic here: it would mark orphans resolved on a + // misconfigured deployment and lose them permanently. + assert.match(bucket, /export async function removeReceiptObject/); + const strict = bodyOf(bucket, "export async function removeReceiptObject"); + // The throw moved into the shared deadline guard when every storage call + // was put under one — the property is unchanged and now applies to ALL of + // them, so it is asserted where it lives plus at this caller's own use. + assert.match(strict, /await withStorageDeadline\("remove"/); + assert.ok(!/return;/.test(strict), "and this one never returns quietly"); + const guard = bodyOf(bucket, "async function withStorageDeadline"); + assert.match(guard, /throw new Error\("receipt storage is not configured"\)/); + // ...and cleanup never reaches for a best-effort variant, or for any bucket + // but the receipts one. + assert.ok(!/removeSecureDoc/.test(cleanup), "cleanup never uses the quiet variant"); + assert.ok(!/SECURE_BUCKET/.test(cleanup), "and never touches the shared document bucket"); +}); + +// -- A repath that cannot sign must not leave its old object behind ---------- + +/** + * Both /start branches that take a NEW lease re-point the row BEFORE signing — + * deliberately, so a sweep cannot reject the row for the OLD upload while a URL + * for the new one is already in the client's hands. + * + * The consequence nobody had followed through: the moment that CAS lands, the + * previous path is unreferenced whatever happens next. The cleanup used to sit + * AFTER the signer, so every `storage-unavailable` return leaked an object that + * nothing pointed at (the row moved), nothing swept (the stale-STAGING sweep + * looks at rows), and nothing remembered (no cleanup event had been recorded). + * + * The fix is ordering, not a new mechanism: the same guarded + * `deleteObjectOrRecord` the happy path already used, moved above the signer. + */ +const startBranches = { + "the recoverable re-arm": start.slice( + start.indexOf("const retryPath ="), + start.indexOf("// IDENTITY MUST BE PROVEN"), + ), + "the expired-lease resume": start.slice(start.indexOf("const resumePath =")), +}; + +test("the OLD object is cleaned up before the signer can fail, in BOTH branches", () => { + for (const [name, branch] of Object.entries(startBranches)) { + assert.notEqual(branch.length, 0, name); + const cleanupAt = branch.search(/repathWithCleanup\(/); + const signAt = branch.indexOf("await signUpload("); + assert.notEqual(cleanupAt, -1, `${name}: the previous lease's object must still be cleaned up`); + assert.notEqual(signAt, -1, `${name}: the branch must still sign a URL`); + assert.ok( + cleanupAt < signAt, + `${name}: the cleanup must run BEFORE the signer, or a 503 orphans the old object`, + ); + // ONE CALL, so the repath and the cleanup entry cannot be separated: + // they are one transaction (see repathWithCleanup). Writing them as two + // statements meant one transient database failure moved the pointer and + // lost the only record of the object it abandoned. + assert.match( + branch, + /repathWithCleanup\(\s*\n?\s*existing,/, + `${name}: the repath and its cleanup go through the shared transaction`, + ); + assert.match( + branch, + /"start-(rearmed|resumed)-repath",/, + `${name}: and it names its own reason`, + ); + // A transaction that could not do BOTH is a 503, never a silent success + // on a row that did not move. + assert.match(branch, /=== "unavailable"/, `${name}: an unrecordable cleanup is retryable`); + assert.match(branch, /=== "conflict"/, `${name}: a lost fence is still a 409`); + } + // The guarded-and-scheduled properties now live in ONE place rather than + // being restated per branch — pin them there. + const helper = bodyOf(start, "async function repathWithCleanup"); + assert.match(helper, /prisma\.\$transaction\(/, "one transaction"); + assert.match(helper, /queueObjectCleanup\(/, "the cleanup is enqueued with the caller's tx"); + assert.match(helper, /cleanupNotBefore\(existing\)/, "and it is SCHEDULED, not immediate"); + // ...and still only when the path actually moved. Queueing the path the + // row was just re-pointed AT would mark the live upload target for deletion. + assert.match(helper, /if \(nextPath !== existing\.storagePath\)/); +}); + +test("a signer failure still answers 503, and the row keeps the NEW lease", () => { + // The row is not rolled back: it holds the new path and a live expiry, so + // the caller's retry lands in reuseLiveLease and is handed a URL over that + // same path. Rolling back instead would re-open the window this ordering + // exists to close. + for (const [name, branch] of Object.entries(startBranches)) { + assert.match(branch, /reason: "storage-unavailable" \}, \{ status: 503 \}/, name); + } +}); + +// ── A live signed upload URL outlives the row that asked for it ───────────── +// +// The failure this section exists for, start to finish: +// +// 1. /start hands a client a signed upload URL, good for two hours. +// 2. The client PUTs its bytes and calls /finalize. +// 3. /finalize seals the bytes at the canonical path, moves the row's pointer +// there, and deletes the UPLOAD object — correct bookkeeping, and +// completely undone by step 4. +// 4. The client (a retrying forwarder, a queued background upload, a phone +// that came back on Wi-Fi) PUTs to the SAME url again, minutes later. The +// URL still works. The object is back. +// 5. Nothing references it: the row points at the canonical path. Nothing +// remembers it: the cleanup already ran and resolved. No sweep looks for +// it: the STAGING sweep reads ROWS, not objects. +// +// The fix is not to delete harder — it is to delete AFTER the capability dies. +// The queue entry is the tombstone that carries the path and the schedule. + +import { + cleanupDue, + cleanupDueAt, + deleteObjectOrRecord, + retryPendingCleanups, + settleQueuedCleanup, + CLEANUP_SCAN_FACTOR, + CLEANUP_SWEEPABLE_STATUSES, + claimsConflict, + acquireObjectClaim, + renewObjectClaim, + DELETE_CLAIM_LEASE_MS, + claimObjectPath, + recordPendingCleanup, + objectClaimDueAt, + OBJECT_CLAIM_LEASE_MS, + type CleanupIo, + type CleanupSweepDeps, +} from "../src/lib/receipt-intake/storage-cleanup"; +import type { Prisma } from "@prisma/client"; +import { CLEANUP_GRACE_MS, cleanupNotBefore } from "../src/lib/receipt-intake/worker"; +import { + STORAGE_CALL_MAX_MS, + isStorageTimeout, + removeReceiptObject, + storageBudgetMs, +} from "../src/lib/receipt-intake/bucket"; +import { createRouteDeadline, type RouteDeadline } from "../src/lib/quickbooks"; +import { sealAndPublish, verifyStoredCopy } from "../src/lib/receipt-intake/stored-object"; + +/** The verified bytes a publish carries. Only the shape matters here. */ +const CHECK = { + mimeType: "image/png", + fileSize: 4, + fileSha256: "b".repeat(64), + bytes: Buffer.from("abcd"), +}; + +const T0 = new Date("2026-09-02T12:00:00.000Z"); +const UPLOAD = "receipts/intake/row-1.v1.bin"; +const CANONICAL = "receipts/row-1/abc.png"; + +/** + * A world with real objects, a real queue and a clock we can move. The whole + * property is about WHEN a delete happens, so a test that cannot advance time + * or watch the bucket cannot assert it. + */ +/** One row of the claim table the fake stands in for. */ +interface ClaimRow { storagePath: string; token: string; kind: string; expiresAt: Date } + +function world(now: Date = T0) { + const objects = new Set(); + const events: { id: string; status: string; detail: string }[] = []; + /** The claim table: one row per path, exactly as the primary key enforces. */ + const claims = new Map(); + let locksTaken = 0; + let rows: { id: string; storagePath: string }[] = []; + let afterClaim: (() => void) | null = null; + let txOpen = 0; + let txOpenMs = 0; + let maxConcurrentTx = 0; + let clock = now.getTime(); + + const record = async (storagePath: string, _reason: string, notBefore: Date | null) => { + events.push({ + id: `ev-${events.length + 1}`, + status: "pending", + detail: JSON.stringify( + notBefore ? { storagePath, notBefore: notBefore.toISOString() } : { storagePath }, + ), + }); + }; + const remove = async (storagePath: string) => { + // The real removeReceiptObject throws on anything short of a confirmed + // removal, and the sweep's resolve depends on exactly that. + if (!objects.delete(storagePath)) throw new Error("NotFound"); + }; + const io: CleanupIo = { + remove, + record, + resolve: async eventId => { + const event = events.find(e => e.id === eventId); + if (event) event.status = "resolved"; + }, + now: () => new Date(clock), + }; + const sweep: CleanupSweepDeps = { + // BOTH sweepable statuses, exactly as the live wiring queries: a + // provisional intent the sweep could not see would make the whole + // account-before-you-write design a note nobody reads. + findPending: async take => + events.filter(e => (CLEANUP_SWEEPABLE_STATUSES as string[]).includes(e.status)).slice(0, take), + abandon: async eventId => { + const event = events.find(e => e.id === eventId); + if (event) event.status = "abandoned"; + }, + // A SHORT transaction, and the fake RECORDS how long it stays open so + // "no storage call happens inside one" is a measured property rather + // than a claim — see the connection-hold test. + inShortTx: async body => { + const openedAt = Date.now(); + txOpen++; + maxConcurrentTx = Math.max(maxConcurrentTx, txOpen); + try { + return await body({ + // THE CLAIM TABLE, with the primary key's uniqueness modelled: one + // row per path, upserted. Two live claims cannot coexist here any + // more than they can in Postgres, which is the invariant the + // advisory lock exists to make reachable in the first place. + receiptObjectClaim: { + findUnique: async ({ where }: { where: { storagePath: string } }) => + claims.get(where.storagePath) ?? null, + upsert: async ( + { where, create, update }: { + where: { storagePath: string }; + create: Record; + update: Record; + }, + ) => { + const held = claims.get(where.storagePath); + const next = held + ? { ...held, ...update } + : { ...(create as { storagePath: string; token: string; kind: string; expiresAt: Date }) }; + claims.set(where.storagePath, next as ClaimRow); + return next; + }, + update: async ( + { where, data }: { where: { storagePath: string }; data: Partial }, + ) => { + const held = claims.get(where.storagePath); + if (!held) throw new Error(`no claim for ${where.storagePath}`); + const next = { ...held, ...data }; + claims.set(where.storagePath, next); + return next; + }, + deleteMany: async ({ where }: { where: { storagePath: string; token: string } }) => { + const held = claims.get(where.storagePath); + if (held && held.token === where.token) { + claims.delete(where.storagePath); + return { count: 1 }; + } + return { count: 0 }; + }, + }, + // The per-path advisory lock. A single-threaded fake cannot + // interleave two transactions anyway; that it is really TAKEN, and + // taken FIRST, is asserted on the source and proven against real + // Postgres in receipt-intake-claim-db.test.ts. + $executeRaw: async () => { + locksTaken++; + return 1; + }, + receiptIntake: { + findFirst: async ({ where }: { where: { storagePath: string } }) => + rows.find(r => r.storagePath === where.storagePath) ?? null, + }, + automationEvent: { + // Every PENDING event naming this path — the sweep folds their + // schedules together and takes the latest, so an older event + // can never authorise a delete a newer one is still deferring. + findMany: async ({ where }: { where: { detail: { contains: string } } }) => + events.filter(e => e.status === "pending" && e.detail.includes(where.detail.contains)), + findUnique: async ({ where }: { where: { id: string } }) => + events.find(e => e.id === where.id) ?? null, + update: async ( + { where, data }: { where: { id: string }; data: { status?: string; detail?: string } }, + ) => { + const event = events.find(e => e.id === where.id); + if (event) { + if (data.status !== undefined) event.status = data.status; + // The delete CLAIM is written into `detail`, so the + // fake has to carry it or the pre-delete re-read can + // never confirm the claim it just took. + if (data.detail !== undefined) event.detail = data.detail; + } + return event; + }, + create: async ({ data }: { data: Record }) => { + const created = { + id: `ev-${events.length + 1}`, + status: data.status, + detail: data.detail, + }; + events.push(created); + return created; + }, + updateMany: async () => ({ count: 0 }), + }, + } as never); + } finally { + txOpen--; + txOpenMs += Date.now() - openedAt; + if (afterClaim) { const fn = afterClaim; afterClaim = null; fn(); } + } + }, + remove, + now: () => new Date(clock), + }; + return { + objects, events, io, sweep, claims, + /** How many times the per-path advisory lock was taken. */ + locksTaken: () => locksTaken, + setRows: (next: typeof rows) => { rows = next; }, + /** Runs once, after the sweep's claim tx and before its re-read. */ + onAfterClaim: (fn: () => void) => { afterClaim = fn; }, + advance: (ms: number) => { clock += ms; }, + pending: () => events.filter(e => (CLEANUP_SWEEPABLE_STATUSES as string[]).includes(e.status)), + /** Total time any transaction was open, across the whole sweep. */ + txOpenMs: () => txOpenMs, + maxConcurrentTx: () => maxConcurrentTx, + }; +} + +/** The row as /finalize reads it: a two-hour signed URL issued a moment ago. */ +const leased = { uploadUrlExpiresAt: new Date(T0.getTime() + 2 * 60 * 60_000), createdAt: T0 }; + +test("BOTH destructive /finalize paths carry the schedule, and the sweeper states it too", () => { + // The wiring, pinned: a fix that only reached one of the two paths would + // leave the other issuing exactly the orphan this section is about, and + // every behavioural test above would still pass. + const fin = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + // ONE schedule, computed once from the row, so the reject path and the + // seal path cannot disagree about the same object. + assert.match(fin, /const cleanupAfter = cleanupNotBefore\(row\);/); + assert.match(fin, /uploadUrlExpiresAt: true, createdAt: true,/, "and the row is read for it"); + assert.match(fin, /cleanupNotBefore: cleanupAfter,/, "the reject tombstone carries it"); + assert.match(fin, /settleQueuedCleanup\(rejected\.eventId, row\.storagePath, cleanupAfter\)/); + // ENQUEUED IN THE COMMIT TRANSACTION, not deleted after it: the queue entry + // is the only thing that remembers the upload object once the pointer moves. + assert.match(fin, /queueUploadCleanup: \(tx, uploadPath\) =>\s*\n?\s*queueObjectCleanup\(tx, uploadPath, "sealed", cleanupAfter\)/); + assert.match(fin, /settleUploadCleanup: \(eventId, uploadPath\) =>/); + + // The sweeper publishes while a lease can still be live, so its dropUpload + // needs the same treatment; its reject branch is already gated on a dead + // lease and passes null, which the shared helper computes for itself. + const cron = readFileSync( + path.join(ROOT, "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", + ); + assert.match(cron, /queueObjectCleanup\(tx, uploadPath, "sealed", cleanupNotBefore\(row\)\)/); + assert.match(cron, /cleanupNotBefore: cleanupNotBefore\(row\),/); +}); + +test("an upsert-capable upload URL is issued ONLY by the live-lease reuse", () => { + // The other half of the capability containment: a token that can overwrite + // whatever is at the path outlives the row it was issued for, so only the + // one caller that genuinely needs it gets it. Every other issuer signs a + // path a version bump has just made new. + assert.match(bucket, /createSignedUploadUrl\(path, \{ upsert: opts\.upsert \?\? false \}\)/); + const lease = readFileSync(path.join(ROOT, "src/lib/receipt-intake/upload-lease.ts"), "utf8"); + assert.equal((lease.match(/upsert: true/g) ?? []).length, 1, "exactly one asker"); + assert.equal( + (start.match(/signUpload\([^)]*upsert/g) ?? []).length, + 0, + "/start never asks for it directly", + ); +}); + +test("a live upload lease schedules the cleanup; a dead one does not", () => { + const at = cleanupNotBefore(leased, T0); + assert.ok(at, "a live lease has a schedule"); + assert.equal(at.getTime(), leased.uploadUrlExpiresAt.getTime() + CLEANUP_GRACE_MS); + // A dead lease has none: an immediate delete is correct, and this must not + // quietly defer every cleanup in the system by two hours. + assert.equal(cleanupNotBefore(leased, new Date(leased.uploadUrlExpiresAt.getTime() + 1)), null); + // The SAME rule the STAGING sweep applies — an inline row (no signed URL + // was ever issued) is measured from its own age, not given a URL's grace. + assert.equal( + cleanupNotBefore({ uploadUrlExpiresAt: null, createdAt: new Date(T0.getTime() - 60 * 60_000) }, T0), + null, + ); +}); + +test("SEAL: the upload object is NOT deleted while its URL still works", async () => { + const w = world(); + w.objects.add(UPLOAD); + // The publish committed: the row points at the canonical path now. + w.objects.add(CANONICAL); + w.setRows([{ id: "row-1", storagePath: CANONICAL }]); + + const deleted = await deleteObjectOrRecord(UPLOAD, "sealed", cleanupNotBefore(leased, T0), w.io); + + assert.equal(deleted, false, "nothing was deleted"); + assert.ok(w.objects.has(UPLOAD), "the bytes are still there"); + assert.equal(w.pending().length, 1, "and the queue remembers them"); + assert.equal( + cleanupDueAt(w.pending()[0].detail)?.getTime(), + leased.uploadUrlExpiresAt.getTime() + CLEANUP_GRACE_MS, + ); +}); + +test("the scheduled cleanup runs only AFTER the url can no longer land", async () => { + const w = world(); + w.objects.add(UPLOAD); + w.objects.add(CANONICAL); + w.setRows([{ id: "row-1", storagePath: CANONICAL }]); + await deleteObjectOrRecord(UPLOAD, "sealed", cleanupNotBefore(leased, T0), w.io); + + // BEFORE EXPIRY: the sweep sees the event, refuses to act, and leaves it + // PENDING rather than resolving it away. + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0); + assert.ok(w.objects.has(UPLOAD), "still not deleted"); + assert.equal(w.pending().length, 1, "still queued, not resolved"); + + // THE LATE PUT. The holder's URL is still valid, so this write succeeds — + // and it lands on an object that was never removed, which is the point. + w.advance(60 * 60_000); + w.objects.add(UPLOAD); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0, "still inside the lease"); + + // AFTER EXPIRY + GRACE: the URL cannot write any more, so the delete sticks. + w.advance(2 * 60 * 60_000); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1); + assert.equal(w.objects.has(UPLOAD), false, "the orphan is gone"); + assert.equal(w.pending().length, 0, "and the queue drained"); + assert.ok(w.objects.has(CANONICAL), "the published receipt was never touched"); +}); + +test("CONTROL: deleting immediately is exactly what loses the object", async () => { + // The old behaviour, run through the same fakes. The delete succeeds, the + // queue is left with nothing pending, the still-valid URL puts the bytes + // back — and no later sweep can find them, because nothing recorded them. + const w = world(); + w.objects.add(UPLOAD); + w.objects.add(CANONICAL); + w.setRows([{ id: "row-1", storagePath: CANONICAL }]); + + assert.equal(await deleteObjectOrRecord(UPLOAD, "sealed", null, w.io), true); + assert.equal(w.objects.has(UPLOAD), false); + assert.equal(w.pending().length, 0, "nothing remembers the path"); + + w.objects.add(UPLOAD); // the late PUT + w.advance(24 * 60 * 60_000); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0); + assert.ok(w.objects.has(UPLOAD), "an unreferenced object nothing will ever collect"); +}); + +test("REJECT: the opportunistic settle is a no-op while the url is live", async () => { + // /finalize rejects a row minutes after /start, so this is the path most + // likely to be holding a live capability — unlike the sweeper, which + // refuses to reject at all until the lease is dead. + const w = world(); + w.objects.add(UPLOAD); + await w.io.record(UPLOAD, "unsupported-file-type", cleanupNotBefore(leased, T0)); + + const settled = await settleQueuedCleanup("ev-1", UPLOAD, cleanupNotBefore(leased, T0), w.io); + assert.equal(settled, false); + assert.ok(w.objects.has(UPLOAD), "nothing deleted"); + assert.equal(w.pending().length, 1, "left for the sweep"); + + // With no schedule (the sweeper's case: it already waited the lease out) + // the settle deletes at once, exactly as it always did. + assert.equal(await settleQueuedCleanup("ev-1", UPLOAD, null, w.io), true); + assert.equal(w.objects.has(UPLOAD), false); + assert.equal(w.pending().length, 0); +}); + +test("an unreadable or absent notBefore means NOW, never never", () => { + // A queue entry nothing can ever act on is worse than one that acts early: + // it sits pending forever and its object is never collected. + assert.equal(cleanupDueAt(null), null); + assert.equal(cleanupDueAt('{"storagePath":"p"}'), null); + assert.equal(cleanupDueAt("not json"), null); + assert.equal(cleanupDueAt('{"notBefore":"never"}'), null); + assert.equal(cleanupDue('{"notBefore":"never"}', T0), true); + assert.equal(cleanupDue('{"storagePath":"p"}', T0), true); + // A real schedule is respected in both directions, and the boundary is + // inclusive: at the instant itself the cleanup is due. + const detail = JSON.stringify({ storagePath: "p", notBefore: T0.toISOString() }); + assert.equal(cleanupDue(detail, new Date(T0.getTime() - 1)), false); + assert.equal(cleanupDue(detail, T0), true); +}); + +test("not-yet-due entries do not crowd out the ones that are", async () => { + // `limit` bounds the storage round trips, not the SELECT. With a scan + // window of exactly `limit` these four scheduled entries would fill every + // slot and the due one behind them would never be reached — for as long as + // a client kept re-arming leases ahead of it. + const w = world(); + const future = new Date(T0.getTime() + 60 * 60_000); + for (let i = 0; i < CLEANUP_SCAN_FACTOR - 1; i++) { + const path = `receipts/intake/later-${i}.bin`; + w.objects.add(path); + await w.io.record(path, "sealed", future); + } + const dueNow = "receipts/intake/due.bin"; + w.objects.add(dueNow); + await w.io.record(dueNow, "sealed", null); + + assert.equal(await retryPendingCleanups(1, () => false, w.sweep), 1, "the due one was reached"); + assert.equal(w.objects.has(dueNow), false); + assert.equal( + w.pending().length, + CLEANUP_SCAN_FACTOR - 1, + "the scheduled ones are untouched, and left pending rather than resolved", + ); +}); + +// ── "Durable" cleanup must not degrade to best-effort (Codex round-12 item 3) ── +// +// The cleanup record is the ONLY thing that remembers an object once the row +// stops pointing at it. `deleteObjectOrRecord` used to catch the failure to +// write that record and return `false`, and every caller discarded the `false` +// AFTER it had already moved the pointer — so one transient database error +// left bytes in a private bucket that no row referenced, no event remembered +// and no sweep would ever look at. Silently, and permanently. +// +// Two halves to the fix, and one test each: +// - the swallow is gone, so a caller with no transaction sees the failure; +// - callers that MOVE a pointer enqueue inside that pointer's transaction, +// so either both land or neither does. + +test("an unrecordable cleanup THROWS instead of reporting a quiet false", async () => { + const w = world(); + w.objects.add(UPLOAD); + const io: CleanupIo = { ...w.io, record: async () => { throw new Error("db is down"); } }; + + // The scheduled branch: nothing is deleted, and the caller is told. + await assert.rejects( + () => deleteObjectOrRecord(UPLOAD, "sealed", cleanupNotBefore(leased, T0), io), + /db is down/, + ); + assert.ok(w.objects.has(UPLOAD), "nothing deleted"); + + // ...and the delete-failed branch, which is the one that actually loses an + // object: storage refused AND the queue refused. + const gone: CleanupIo = { + ...io, + remove: async () => { throw new Error("storage refused"); }, + }; + await assert.rejects(() => deleteObjectOrRecord(UPLOAD, "sealed", null, gone), /db is down/); + assert.ok(w.objects.has(UPLOAD), "still there, and now remembered by the throw"); +}); + +test("CONTROL: a recordable cleanup still returns rather than throwing", async () => { + // Without this, a function that simply always threw would pass the test + // above while breaking every ordinary cleanup. + const w = world(); + w.objects.add(UPLOAD); + assert.equal(await deleteObjectOrRecord(UPLOAD, "sealed", cleanupNotBefore(leased, T0), w.io), false); + assert.equal(w.pending().length, 1, "queued, not thrown"); + assert.equal(await deleteObjectOrRecord(UPLOAD, "sealed", null, w.io), true, "and a due one deletes"); + assert.equal(w.objects.has(UPLOAD), false); +}); + +test("a cleanup-record failure ROLLS BACK the pointer transition it belongs to", async () => { + // The seal path, end to end: the row's pointer moves to the canonical path + // and the upload object becomes an orphan in the same instant, so the + // record of that orphan has to commit with the move. + const store = { storagePath: UPLOAD, state: "STAGING" }; + let committed = false; + // Everything the body writes goes here first; only a body that returns + // without throwing is copied onto `store`. That is what makes this a real + // rollback rather than a mutation the assertions cannot see. + let staged = { ...store }; + const attempt = (queueThrows: boolean) => sealAndPublish(UPLOAD, "row-1", 1, CHECK, { + inShortTx: async (body: (tx: never) => Promise) => { + staged = { ...store }; + const out = await body({ + receiptIntake: { findUnique: async () => ({ storagePath: staged.storagePath }) }, + } as never); + Object.assign(store, staged); + committed = true; + return out; + }, + seal: async (_u: string, canonical: string) => canonical, + commit: async (_tx: never, canonical: string) => { + staged.storagePath = canonical; + staged.state = "RECEIVED"; + return 1; + }, + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => { + if (queueThrows) throw new Error("cleanup insert failed"); + return "ev-1"; + }, + settleUploadCleanup: async () => {}, + } as never, undefined); + + const failed = await attempt(true); + assert.equal(failed, null, "the publish reports the retryable answer"); + assert.equal(committed, false, "the transaction never committed"); + assert.equal(store.storagePath, UPLOAD, "the row still points at its object"); + assert.equal(store.state, "STAGING", "so nothing is orphaned, and the retry can recover"); + + // CONTROL: the identical run with a queue that works publishes normally — + // otherwise the assertions above would pass for a seal that never commits. + const ok = await attempt(false); + assert.equal(ok?.published, true); + assert.equal(committed, true); + assert.notEqual(store.storagePath, UPLOAD, "the pointer moved"); +}); + +test("the repath helper puts the CAS and the cleanup in one transaction", () => { + // /start has no sealAndPublish to hang the enqueue off, so the two writes + // are wrapped explicitly. Pinned because the failure it prevents — a + // repath that commits without its cleanup entry — leaves no trace anywhere + // to test against after the fact. + const helper = bodyOf(start, "async function repathWithCleanup"); + const txAt = helper.indexOf("prisma.$transaction("); + const casAt = helper.indexOf("updateMany("); + const queueAt = helper.indexOf("queueObjectCleanup("); + assert.ok(txAt >= 0 && txAt < casAt, "the transaction opens first"); + assert.ok(casAt < queueAt, "the CAS runs, then the cleanup is enqueued with the same tx"); + assert.match(helper, /queueObjectCleanup\(\s*\n?\s*tx,/, "with the TRANSACTION's client"); + // A throw out of either one is the caller's 503, never a silent success. + assert.match(helper, /return "unavailable"/); +}); + +test("the NEWEST schedule for a path wins, never the event's own", async () => { + // An event records the expiry its author OBSERVED, and that author can be + // overtaken: a /start refresh extends the lease, and a second cleanup for + // the same path is queued with a LATER deadline. Acting on the older event + // would delete the object while the refreshed URL still works — the exact + // orphan the schedule exists to prevent, arriving through the queue rather + // than through the fence. + const w = world(); + w.objects.add(UPLOAD); + // The first author saw a lease that has since expired... + await w.io.record(UPLOAD, "sealed", new Date(T0.getTime() - 60_000)); + // ...and a second, later author saw the refreshed one. + const refreshedUntil = new Date(T0.getTime() + 60 * 60_000); + await w.io.record(UPLOAD, "sealed", refreshedUntil); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0, "deferred to the newer one"); + assert.ok(w.objects.has(UPLOAD), "the object the live URL can still write survives"); + assert.equal(w.pending().length, 2, "and BOTH events are left pending, not resolved"); + + // Once the newest schedule passes, the object goes and the queue drains. + w.advance(2 * 60 * 60_000); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1); + assert.equal(w.objects.has(UPLOAD), false); + assert.equal(w.pending().length, 0, "the sibling was cleared with it"); +}); + +test("CONTROL: with no newer sibling, the due event deletes at once", async () => { + // Without this the assertions above would pass for a sweep that had simply + // stopped deleting anything. + const w = world(); + w.objects.add(UPLOAD); + await w.io.record(UPLOAD, "sealed", new Date(T0.getTime() - 60_000)); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1); + assert.equal(w.objects.has(UPLOAD), false); +}); + +test("a newer schedule on a DIFFERENT path does not defer this one", async () => { + // The lookup is matched on the JSON-quoted path, so a prefix cannot widen + // it — otherwise one deferred cleanup would hold up every path it prefixes. + const w = world(); + const due = "receipts/intake/row-1.v1.bin"; + const other = "receipts/intake/row-1.v1.bin.other"; + w.objects.add(due); + w.objects.add(other); + await w.io.record(due, "sealed", new Date(T0.getTime() - 60_000)); + await w.io.record(other, "sealed", new Date(T0.getTime() + 60 * 60_000)); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1, "the due path is not held up"); + assert.equal(w.objects.has(due), false); + assert.ok(w.objects.has(other), "and the deferred one is untouched"); +}); + +// ── The canonical copy is accounted for BEFORE it is written (round-15 #2) ── +// +// `sealAndPublish` writes the canonical object to Supabase and only then runs +// the database CAS that points a row at it. Everything after that write can +// fail — the commit, the winner lookup, the transaction — and the object was +// then in the bucket with nothing referencing it, nothing remembering it and +// no sweep looking for it, because the stale-STAGING sweep reads ROWS. A later +// re-arm moves the row elsewhere and the sealed copy is undiscoverable. + +test("a provisional intent is swept like any other cleanup, and resolves with it", async () => { + const w = world(); + const CANON = "receipts/row-1/v1/abc.png"; + w.objects.add(CANON); + // What queueCanonicalIntent writes: same queue, same schedule, its own + // status so the publish lock's reclaim cannot cancel it. + w.events.push({ + id: "intent-1", + status: "provisional", + detail: JSON.stringify({ storagePath: CANON }), + }); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1, "the sweep sees it"); + assert.equal(w.objects.has(CANON), false, "and the unreferenced copy is collected"); + assert.equal(w.pending().length, 0); +}); + +test("a provisional intent NEVER deletes an object a row is using", async () => { + // The safety property the whole design rests on: an intent that outlived a + // publish which actually worked must resolve harmlessly. The sweeper's + // live-reference recheck runs inside the path lock, so a committed pointer + // always wins. + const w = world(); + const CANON = "receipts/row-1/v1/abc.png"; + w.objects.add(CANON); + w.setRows([{ id: "row-1", storagePath: CANON }]); + w.events.push({ + id: "intent-1", + status: "provisional", + detail: JSON.stringify({ storagePath: CANON }), + }); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0); + assert.ok(w.objects.has(CANON), "the published receipt is untouched"); + assert.equal(w.pending().length, 0, "and the intent is resolved, not retried forever"); +}); + +test("the publish lock's reclaim must NOT cancel a provisional intent", () => { + // The ordering trap: the intent is taken out before the lock, and + // `withReceiptPublishLock` reclaims pending cleanups for the path as its + // first act. If reclaim covered provisional too, every publish would + // cancel the intent it had just taken out to survive its own failure. + const cleanup = readFileSync(path.join(ROOT, "src/lib/receipt-intake/storage-cleanup.ts"), "utf8"); + const reclaim = bodyOf(cleanup, "async function reclaimQueuedCleanups"); + assert.match(reclaim, /status: "pending",/, "pending only"); + assert.ok(!reclaim.includes("CLEANUP_SWEEPABLE_STATUSES"), "provisional is deliberately excluded"); + // The sweep, by contrast, must cover both — or the intent is a note + // nobody ever reads. + // Scoped to the QUERY, not to a `bodyOf` slice: `const liveSweepDeps` ends + // in `};` rather than `}`, so the helper ran past it and the sibling + // lookup further down satisfied this assertion on its own — the pin passed + // while the sweep query itself had been mutated away. + const findPending = cleanup.slice( + cleanup.indexOf("findPending: take => prisma.automationEvent.findMany("), + cleanup.indexOf("orderBy: { createdAt: \"asc\" }"), + ); + assert.ok(findPending.length > 0 && findPending.length < 600, "the slice is the query, not the file"); + assert.match(findPending, /status: \{ in: CLEANUP_SWEEPABLE_STATUSES \}/); + // The sibling lookup inside the delete needs it too, for its own reason: + // an intent naming the same object carries a schedule the delete must + // respect, and must be resolved with it. + const siblingsAt = cleanup.indexOf("const siblings = await tx.automationEvent.findMany("); + assert.ok(siblingsAt > 0, "the sibling lookup exists"); + // Searched FROM the sibling lookup: `select: { id: true, detail: true }` + // also closes findPending above, so a plain indexOf ran backwards and + // produced an empty slice that matched nothing and failed loudly. + const siblings = cleanup.slice( + siblingsAt, + cleanup.indexOf("select: { id: true, detail: true }", siblingsAt), + ); + assert.match(siblings, /status: \{ in: CLEANUP_SWEEPABLE_STATUSES \}/); + assert.deepEqual(CLEANUP_SWEEPABLE_STATUSES, ["pending", "provisional"]); +}); + +// ── An ambiguous healing upload is accounted for (round-17 item 1) ───────── +// +// `uploadReceiptObject` returns false for a REFUSAL and for an AMBIGUOUS +// outcome alike — a write storage may well have accepted before the response +// was lost. The heal branch answered 503 and recorded nothing, so those bytes +// sat in a private bucket with no row pointing at them (the row still points +// at its OLD path), no event remembering them, and no sweep looking: the +// stale-STAGING sweep reads ROWS, and this row is not STAGING. + +test("the heal CLAIMS its path before uploading, and settles the claim with the repoint", () => { + const intake = readFileSync(path.join(ROOT, "src/app/api/receipts/intake/route.ts"), "utf8"); + const heal = intake.slice(intake.indexOf("const healable = finalizeDisposition(existing)")); + const body = heal.slice(0, heal.indexOf("// A booked/archived row with no object")); + + const claimAt = body.indexOf("claimObjectPath("); + const uploadAt = body.indexOf("await storeObject("); + const repointAt = body.indexOf("await inShortTx("); + assert.ok(claimAt > 0, "the path is claimed"); + assert.ok(claimAt < uploadAt, "BEFORE the upload — an object we cannot promise to clean up is not written"); + assert.ok(uploadAt < repointAt, "and the upload is outside the transaction that repoints the row"); + + // A claim that cannot be recorded uploads NOTHING. + const claimFail = body.slice(claimAt, repointAt); + assert.match(claimFail, /reason: "storage-unavailable", retryable: true/); + + // The ambiguous outcome leaves the intent standing rather than resolving it. + assert.match(body, /AMBIGUOUS: storage may hold the bytes/); + assert.match(body, /if \(moved\.count > 0\) await resolveCanonicalIntent\(tx, healIntentId\);/); +}); + +test("an ambiguous heal leaves an intent the sweeper can act on", async () => { + // End to end through the queue: the intent is recorded with a lease, the + // sweeper defers while that lease is live, and collects the orphan once it + // lapses — rechecking live references first. + const w = world(); + const HEAL = "receipts/intake/heal-1.png"; + // Phase A wrote this and then the upload came back ambiguous. + w.objects.add(HEAL); // storage DID accept the bytes + w.events.push({ + id: "intent-heal", + status: "provisional", + detail: JSON.stringify({ storagePath: HEAL, notBefore: new Date(T0.getTime() + 60_000).toISOString() }), + }); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0, "deferred while the lease is live"); + assert.ok(w.objects.has(HEAL)); + + w.advance(2 * 60_000); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1, "collected once it lapsed"); + assert.equal(w.objects.has(HEAL), false); + + // PRE-FIX CONTROL: with no intent recorded there is nothing for the sweep + // to find, and the bytes stay in the bucket forever. + const leaked = world(); + leaked.objects.add(HEAL); + leaked.advance(24 * 60 * 60_000); + assert.equal(await retryPendingCleanups(10, () => false, leaked.sweep), 0); + assert.ok(leaked.objects.has(HEAL), "unrecorded bytes are unreachable, which was the bug"); +}); + +test("a heal that WINS its CAS cancels the intent — the control", async () => { + // Otherwise every successful heal would leave a live intent that the + // sweeper later resolves against a referenced row: harmless, but it would + // mean the cancellation was never actually wired. + const w = world(); + const HEAL = "receipts/intake/heal-2.png"; + w.objects.add(HEAL); + w.setRows([{ id: "row-1", storagePath: HEAL }]); + w.events.push({ + id: "intent-heal-2", + status: "provisional", + detail: JSON.stringify({ storagePath: HEAL }), + }); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0); + assert.ok(w.objects.has(HEAL), "the healed row's bytes are never collected"); + assert.equal(w.pending().length, 0, "and the intent is resolved by the reference check"); +}); + +test("the CLAIM lease covers the seal window, and yields to a longer one", () => { + // The lease is what replaced the advisory lock: while it is live the + // sweeper skips the path, so the object cannot be collected between the + // seal and the pointer commit. + const now = new Date("2026-09-03T12:00:00.000Z"); + + // No caller schedule at all — the sweeper's own publish path, where the + // upload URL has long since lapsed. Taking the caller's null here would + // leave the seal window completely unguarded. + const bare = objectClaimDueAt(null, now); + assert.equal(bare.getTime(), now.getTime() + OBJECT_CLAIM_LEASE_MS); + assert.ok(OBJECT_CLAIM_LEASE_MS > STORAGE_CALL_MAX_MS, "longer than a storage call may take"); + + // A caller schedule that has ALREADY passed must not shorten the lease. + const stale = objectClaimDueAt(new Date(now.getTime() - 60_000), now); + assert.equal(stale.getTime(), now.getTime() + OBJECT_CLAIM_LEASE_MS, "the publish window still holds"); + + // A LIVE upload URL outlives this publish, so its schedule wins. + const liveUrl = new Date(now.getTime() + 2 * 60 * 60_000); + assert.equal(objectClaimDueAt(liveUrl, now).getTime(), liveUrl.getTime()); +}); + +test("a claimed path is NOT swept while its lease is live — the whole point", async () => { + // End to end through the queue, which is where the lock's job now lives. + const w = world(); + const CANON = "receipts/row-1/v1/sealed.png"; + w.objects.add(CANON); + w.events.push({ + id: "intent-live", + status: "provisional", + detail: JSON.stringify({ storagePath: CANON, notBefore: objectClaimDueAt(null, T0).toISOString() }), + }); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 0, "the lease holds the path"); + assert.ok(w.objects.has(CANON), "the bytes a publish is about to point at survive"); + + // ...and once it lapses without being resolved, the publish is presumed + // dead and the orphan is collected. + w.advance(OBJECT_CLAIM_LEASE_MS + 1_000); + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1); + assert.equal(w.objects.has(CANON), false); +}); + +// ── A cleanup may not delete an object a publish is writing (round-18 #1) ── +// +// The sweep's verdict is reached in one transaction and acted on after it +// commits. When that transaction persisted NOTHING, the two operations could +// interleave fatally: +// +// 1. sweep reads: unreferenced, due -> decides "delete" (tx commits) +// 2. publisher claims the path and seals a NEW object into it +// 3. sweep removes -> the object the publisher is about to point at is gone +// +// Neither party is wrong; nothing recorded that the other had started. The fix +// is a durable, mutually exclusive per-path claim. + +const claimDetail = (kind: string, until: number, token = "t") => JSON.stringify({ + storagePath: "p", + claimToken: token, + claimKind: kind, + claimUntil: new Date(until).toISOString(), +}); + +test("CLAIMS ARE EXCLUSIVE: publishing and deleting cannot both hold a path", () => { + const now = new Date("2026-09-03T12:00:00.000Z"); + const live = (kind: string) => ({ detail: claimDetail(kind, now.getTime() + 60_000) }); + + // A publisher is refused while a delete holds the path, and vice versa. + assert.equal(claimsConflict([live("deleting")], "publishing", now), "deleting"); + assert.equal(claimsConflict([live("publishing")], "deleting", now), "publishing"); + // Two deleters may not share it either. + assert.equal(claimsConflict([live("deleting")], "deleting", now), "deleting"); + // TWO PUBLISHERS MAY. The canonical path is content-addressed, so they are + // writing identical bytes and the seal is an upsert; only the pointer needs + // serializing, and the phase-C CAS does that. + assert.equal(claimsConflict([live("publishing")], "publishing", now), null); + // A LAPSED claim holds nothing — that is what makes a dead process + // recoverable rather than a permanent block. + const lapsed = { detail: claimDetail("deleting", now.getTime() - 1) }; + assert.equal(claimsConflict([lapsed], "publishing", now), null); + // And an entry carrying no claim at all blocks nobody. + assert.equal(claimsConflict([{ detail: JSON.stringify({ storagePath: "p" }) }], "deleting", now), null); +}); + +test("ORDERING: sweep decides, publisher claims, sweep is REFUSED", async () => { + // The exact interleaving, driven through the shipped sweep. The publisher + // lands in the gap between the sweep's claim transaction and its re-read. + const w = world(); + const CANON = "receipts/row-1/v1/contested.png"; + w.objects.add(CANON); + w.events.push({ id: "ev-old", status: "pending", detail: JSON.stringify({ storagePath: CANON }) }); + + w.onAfterClaim(() => { + // A publisher takes the path -- in the CLAIM TABLE, the one place a + // claim lives -- and seals a NEW object into it. In production the + // advisory lock makes this impossible while the sweep's own claim + // transaction is open; here it is forced into the gap AFTER that + // transaction commits, which is exactly the window the pre-delete + // re-read exists to close. + w.claims.set(CANON, { + storagePath: CANON, + token: "publisher-token", + kind: "publishing", + expiresAt: new Date(Date.now() + 60_000), + }); + w.objects.add(CANON); + }); + + const cleared = await retryPendingCleanups(10, () => false, w.sweep); + + assert.equal(cleared, 0, "the sweep deleted nothing"); + assert.ok(w.objects.has(CANON), "the object the publisher sealed survives"); +}); + +test("CONTROL: with no claim recorded, the same interleaving DELETES it", async () => { + // The pre-fix world: the sweep's verdict persists nothing, so nothing + // stands between the decision and the delete. + const w = world(); + const CANON = "receipts/row-1/v1/contested.png"; + w.objects.add(CANON); + w.events.push({ id: "ev-old", status: "pending", detail: JSON.stringify({ storagePath: CANON }) }); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1); + assert.equal(w.objects.has(CANON), false, "an unclaimed path IS deleted — that was the bug"); +}); + +test("a publisher REFUSES a path a delete is holding", async () => { + // The other direction of the same exclusion, decided in one place now: + // acquireObjectClaim, under the per-path lock, against a table whose + // primary key is the path. + const src = readFileSync(path.join(ROOT, "src/lib/receipt-intake/storage-cleanup.ts"), "utf8"); + const claim = bodyOf(src, "export async function claimObjectPath"); + assert.match(claim, /acquireObjectClaim\(tx, canonicalPath, "publishing", until, now\)/); + assert.match(claim, /throw new ObjectPathBusyError/); + // One transaction: the read, the reclaim and the claim write cannot be + // split, or a deleter takes the path between them. + assert.match(claim, /return run\(async tx => \{/); + // ...and `run` DEFAULTS to inShortTx, so the seam that makes the write + // observable cannot become a way to run it outside a transaction. + assert.match(claim, /=> Promise = inShortTx,/); +}); + +// ── ONE deadline per invocation, shared by every storage call (round-18 #3) ── +// +// `deadline` used to be optional on every helper in bucket.ts, and the callers +// under /finalize never passed one. So a single publish made three storage +// calls — the size probe, the download and the seal upload — and each one +// computed its own budget from `undefined`, taking a fresh fifteen seconds. +// Forty-five seconds of allowance inside a handler the platform kills at +// thirty: the invocation dies mid-seal, having spent its life on calls whose +// answers it could no longer use. Making the parameter NON-OPTIONAL is the +// fix, because it makes the compiler enumerate every caller rather than +// leaving the omission invisible. + +/** A deadline that started `elapsed` ms ago. Real clock, no fake timers. */ +const started = (elapsed: number, budgetMs = ROUTE_BUDGET_FOR_TEST) => + createRouteDeadline(budgetMs, Date.now() - elapsed); +const ROUTE_BUDGET_FOR_TEST = 20_000; + +test("SEQUENTIAL calls draw down ONE budget, and it shrinks", () => { + // At the top of the request the cap still applies: a single call may never + // take more than STORAGE_CALL_MAX_MS even when the route has more left. + assert.equal(storageBudgetMs(started(0)), STORAGE_CALL_MAX_MS); + + // Eight seconds in — say the size probe was slow — the NEXT call gets what + // is actually left, not another full allowance. + const afterFirst = storageBudgetMs(started(8_000)); + assert.ok(afterFirst <= 12_000 && afterFirst > 11_000, `saw ${afterFirst}`); + assert.ok(afterFirst < STORAGE_CALL_MAX_MS, "strictly less than a fresh allowance"); + + // Eighteen seconds in, the third call gets two seconds. It still runs — + // a short call may well finish — but it cannot straddle the ceiling. + const afterSecond = storageBudgetMs(started(18_000)); + assert.ok(afterSecond <= 2_000 && afterSecond > 1_000, `saw ${afterSecond}`); + assert.ok(afterSecond < afterFirst, "monotonically shrinking"); + + // Past the end there is nothing, and a negative remainder is clamped. + assert.equal(storageBudgetMs(started(25_000)), 0); +}); + +test("the LAST call REFUSES to start when there is no runway", async () => { + // Through the real exported helper, so this is the shipped gate and not a + // restatement of it. The budget check precedes the client, so this needs no + // Supabase configuration and makes no network call. + await assert.rejects( + () => removeReceiptObject("receipts/intake/row-1/x.png", started(19_900)), + (error: unknown) => { + assert.ok(isStorageTimeout(error), `saw ${(error as Error)?.name}`); + assert.match((error as Error).message, /storage-timeout:remove/); + return true; + }, + "starting a call it cannot finish is how a pass spends its last milliseconds", + ); + + // ...and with runway it gets past the gate. It fails later, for a different + // reason, which is the point: the refusal above was the DEADLINE, not the + // environment. + await assert.rejects( + () => removeReceiptObject("receipts/intake/row-1/x.png", started(0)), + (error: unknown) => { + assert.equal(isStorageTimeout(error), false, "not a budget refusal"); + return true; + }, + ); +}); + +test("PRE-FIX CONTROL: an omitted deadline hands every call a fresh 15s", () => { + // This is exactly what the callers did before this round: pass nothing. + assert.equal(storageBudgetMs(undefined), STORAGE_CALL_MAX_MS); + // Three calls, three full allowances, no draw-down between them. + const independent = [undefined, undefined, undefined].map(d => storageBudgetMs(d)); + assert.deepEqual(independent, [STORAGE_CALL_MAX_MS, STORAGE_CALL_MAX_MS, STORAGE_CALL_MAX_MS]); + assert.ok( + independent.reduce((a, b) => a + b, 0) > 30_000, + "45s of allowance inside a 30s invocation — the bug, stated arithmetically", + ); + // And the gate would have let the third one start with the route already + // over. Compare with the shared-budget case above, which refuses. + assert.ok(storageBudgetMs(undefined) >= 500, "no runway check is possible without a deadline"); +}); + +test("every storage helper REQUIRES the deadline, so the compiler finds the callers", () => { + // The structural half of the fix. An optional parameter is silently + // omittable at every call site; a required one is a compile error, which is + // how the missing callers were found in the first place. + const src = readFileSync(path.join(ROOT, "src/lib/receipt-intake/bucket.ts"), "utf8"); + for (const fn of [ + "receiptObjectSize", + "downloadReceiptObject", + "uploadReceiptObject", + "removeReceiptObject", + "createReceiptUploadUrl", + "signReceiptDownloadUrl", + ]) { + const at = src.indexOf(`export async function ${fn}(`); + assert.ok(at > 0, `${fn} is exported`); + const sig = src.slice(at, src.indexOf("): Promise", at)); + assert.match(sig, /deadline: RouteDeadline \| undefined/, `${fn} takes the deadline`); + assert.ok(!/deadline\?: /.test(sig), `${fn}'s deadline is NOT optional`); + } + + // And each intake route creates exactly ONE, at the top. + for (const route of [ + "src/app/api/receipts/intake/route.ts", + "src/app/api/receipts/intake/start/route.ts", + ]) { + const body = readFileSync(path.join(ROOT, route), "utf8"); + const made = body.match(/createRouteDeadline\(/g) ?? []; + assert.equal(made.length, 1, `${route} creates one deadline, not one per call`); + assert.match(body, /const ROUTE_BUDGET_MS = 2[0-9]_000;/, `${route} budgets under the platform ceiling`); + } +}); + +// ── The claim a publisher takes is WRITTEN, not merely decided ───────────── +// +// B1-c survived the first mutation battery: setting `claimObjectPath`'s claim +// argument to null — so the verdict persisted nothing — broke no test. Every +// exclusion test above drove `claimsConflict` or the sweep directly, and the +// publisher's own write was covered by a source pin, which a null argument +// walks straight past. These drive the shipped function. + +/** A tx fake that honours `contains` for real, because path identity is the point. */ +function claimWorld( + seed: { id: string; status: string; detail: string }[] = [], + heldClaims: ClaimRow[] = [], +) { + const events = seed.map(e => ({ ...e })); + const claims = new Map(heldClaims.map(c => [c.storagePath, { ...c }])); + let locksTaken = 0; + const tx = { + // The per-path advisory lock. Its ORDER is asserted on the source and + // its EFFECT against real Postgres; here it is only counted, because a + // single-threaded fake has nothing to serialize. + $executeRaw: async () => { + locksTaken += 1; + return 1; + }, + receiptObjectClaim: { + findUnique: async ({ where }: { where: { storagePath: string } }) => + claims.get(where.storagePath) ?? null, + upsert: async ( + { where, create, update }: { + where: { storagePath: string }; + create: ClaimRow; + update: Partial; + }, + ) => { + const held = claims.get(where.storagePath); + const next = held ? { ...held, ...update } : { ...create }; + claims.set(where.storagePath, next as ClaimRow); + return next; + }, + deleteMany: async ({ where }: { where: { storagePath: string; token: string } }) => { + const held = claims.get(where.storagePath); + if (held && held.token === where.token) { + claims.delete(where.storagePath); + return { count: 1 }; + } + return { count: 0 }; + }, + }, + automationEvent: { + findMany: async ({ where }: { where: { status: { in: string[] }; detail: { contains: string } } }) => + events.filter(e => where.status.in.includes(e.status) && e.detail.includes(where.detail.contains)), + updateMany: async ( + { where, data }: { where: { detail: { contains: string } }; data: { status: string } }, + ) => { + let count = 0; + for (const e of events) { + if (!e.detail.includes(where.detail.contains)) continue; + e.status = data.status; + count += 1; + } + return { count }; + }, + create: async ({ data }: { data: { status: string; detail: string } }) => { + const created = { id: `ev-${events.length + 1}`, status: data.status, detail: data.detail }; + events.push(created); + return created; + }, + }, + } as unknown as Prisma.TransactionClient; + return { + events, + claims, + locksTaken: () => locksTaken, + run: async (body: (t: Prisma.TransactionClient) => Promise) => body(tx), + }; +} + +test("claimObjectPath WRITES the publishing claim, and a deleter is then refused", async () => { + const w = claimWorld(); + const CANON = "receipts/intake/row-9/v2/abc.png"; + const now = new Date("2026-09-03T12:00:00.000Z"); + + const id = await claimObjectPath(CANON, null, now, w.run); + + const written = w.events.find(e => e.id === id); + assert.ok(written, "the id names a row that exists — not one it went looking for"); + assert.equal(written.status, "provisional", "invisible to reclaim, visible to the sweeper"); + const detail = JSON.parse(written.detail) as Record; + assert.equal(detail.storagePath, CANON); + assert.equal(detail.claimKind, "publishing"); + assert.ok(detail.claimToken && detail.claimToken.length > 8, "a real token, not a placeholder"); + assert.equal(detail.claimUntil, objectClaimDueAt(null, now).toISOString(), "the lease covers the seal"); + + // THE POINT: feed the row that was actually persisted back through the + // exclusion rule. A sweeper reading the queue now finds the path held. + assert.equal(claimsConflict(w.events, "deleting", now), "publishing"); + // ...and a second publisher is not blocked, because the path is content + // addressed and the seal is an upsert. + assert.equal(claimsConflict(w.events, "publishing", now), null); +}); + +/** A live claim over `path`, as the claim table holds one. */ +const heldClaim = (path: string, kind: string, until: number, token = "t"): ClaimRow => ({ + storagePath: path, + token, + kind, + expiresAt: new Date(until), +}); + +test("a publisher is REFUSED, and writes nothing, when a delete holds the path", async () => { + const CANON = "receipts/intake/row-9/v2/abc.png"; + const now = new Date("2026-09-03T12:00:00.000Z"); + // The delete's claim lives in the CLAIM TABLE now -- one row per path, + // primary-keyed -- rather than inside an event's JSON where nothing could + // enforce it and two transactions could each read the path as free. + const w = claimWorld( + [{ id: "sweep-intent", status: "pending", detail: JSON.stringify({ storagePath: CANON }) }], + [heldClaim(CANON, "deleting", now.getTime() + 30_000, "sweep-token")], + ); + + await assert.rejects( + () => claimObjectPath(CANON, null, now, w.run), + (error: unknown) => (error as Error).name === "ObjectPathBusyError", + ); + assert.equal(w.claims.get(CANON)?.token, "sweep-token", "the deleter still holds it"); + assert.equal(w.events.length, 1, "and nothing was written"); + assert.equal(w.events[0].status, "pending", "not reclaimed out from under it"); + // AND THE LOCK WAS TAKEN, before any of that was decided. + assert.ok(w.locksTaken() >= 1, "the per-path lock is taken"); +}); + +test("path identity is EXACT: a longer path that starts with this one is untouched", async () => { + // `contains` on the bare path matches any path this one prefixes. The + // detail is `{"storagePath":"",...}`, so the match is made on the + // JSON-QUOTED path and the closing quote bounds it. + const CANON = "receipts/intake/row-9/v2/abc.png"; + const SIBLING = `${CANON}.orig.png`; + const now = new Date("2026-09-03T12:00:00.000Z"); + const w = claimWorld([ + { id: "other", status: "pending", detail: JSON.stringify({ storagePath: SIBLING }) }, + ]); + + await claimObjectPath(CANON, null, now, w.run); + + const other = w.events.find(e => e.id === "other"); + assert.equal(other?.status, "pending", "a DIFFERENT object's cleanup was not cancelled"); + assert.equal(w.events.length, 2, "and exactly one claim was added"); +}); + +test("CONTROL: the sibling's OWN claim does reach it", async () => { + // Without this, a matcher that matched nothing at all would pass the test + // above while making every reclaim a no-op. + const SIBLING = "receipts/intake/row-9/v2/abc.png.orig.png"; + const now = new Date("2026-09-03T12:00:00.000Z"); + const w = claimWorld([ + { id: "other", status: "pending", detail: JSON.stringify({ storagePath: SIBLING }) }, + ]); + + await claimObjectPath(SIBLING, null, now, w.run); + + assert.equal(w.events.find(e => e.id === "other")?.status, "resolved", "its own path DOES match"); +}); + +// ── A cleanup intent is DURABLY recorded, or the caller hears about it ───── +// +// B2-a survived too: making `recordPendingCleanup` swallow its failure and +// return a fabricated id broke nothing, because every throw test drove +// `deleteObjectOrRecord`'s injected `record`. This drives the real one. + +test("recordPendingCleanup PROPAGATES a failed insert instead of inventing an id", async () => { + const exploding = { + automationEvent: { create: async () => { throw new Error("db is down"); } }, + } as unknown as Prisma.TransactionClient; + + await assert.rejects( + () => recordPendingCleanup("receipts/intake/row-9/v2/abc.png", "orphan", null, "pending", exploding), + /db is down/, + "a swallowed insert returns SOME id — an older event's, with its stale deadline", + ); +}); + +test("CONTROL: recordPendingCleanup returns the id of the row it actually wrote", async () => { + const writes: { status: string; detail: string }[] = []; + const ok = { + automationEvent: { + create: async ({ data }: { data: { status: string; detail: string } }) => { + writes.push(data); + return { id: "the-row-it-wrote" }; + }, + }, + } as unknown as Prisma.TransactionClient; + + const id = await recordPendingCleanup("receipts/intake/row-9/v2/abc.png", "orphan", null, "pending", ok); + + assert.equal(id, "the-row-it-wrote", "the create's own id, never a search result"); + assert.equal(writes.length, 1); + assert.equal(JSON.parse(writes[0].detail).storagePath, "receipts/intake/row-9/v2/abc.png"); +}); + +test("a DIFFERENT object's live claim does not block this path either", async () => { + // The conflict read is bounded the same way the reclaim is. Matched on the + // bare path, a delete holding `.orig.png` would refuse every publish + // of `` for the length of its lease — a live object held hostage by + // an unrelated one whose name happens to start the same way. + const CANON = "receipts/intake/row-9/v2/abc.png"; + const SIBLING = `${CANON}.orig.png`; + const now = new Date("2026-09-03T12:00:00.000Z"); + // The claim table is keyed BY PATH, so a claim over a different object is + // a different row and cannot reach this one. That used to be a matching + // question -- `contains` on the bare path would have matched any path this + // one prefixes -- and it is now a structural one. + const w = claimWorld( + [{ id: "other-delete", status: "pending", detail: JSON.stringify({ storagePath: SIBLING }) }], + [heldClaim(SIBLING, "deleting", now.getTime() + 30_000, "sweep-token")], + ); + + const id = await claimObjectPath(CANON, null, now, w.run); + assert.ok(w.events.find(e => e.id === id), "the publish claimed its own path"); + assert.equal(w.claims.get(CANON)?.kind, "publishing"); + assert.equal(w.claims.get(SIBLING)?.token, "sweep-token", "the sibling's is untouched"); + + // CONTROL: the same claim DOES block a publish of the path it names. + const same = claimWorld( + [], + [heldClaim(SIBLING, "deleting", now.getTime() + 30_000, "sweep-token")], + ); + await assert.rejects( + () => claimObjectPath(SIBLING, null, now, same.run), + (error: unknown) => (error as Error).name === "ObjectPathBusyError", + ); +}); + +// -- THE LIVE CALL CHAIN DRAWS ON ONE SHRINKING BUDGET (round-21 #2) ------- +// +// The round-18 fix made the deadline required on bucket.ts's six helpers, and +// the callers one level up then passed `undefined` through their own optional +// parameters -- so `verifyStoredCopy` issued a size probe AND a download with +// no deadline at all, `inspectStoredObject` did the same, and /finalize never +// created one. Required all the way down is what makes the compiler name every +// caller; this drives the chain and watches the budget actually shrink. + +/** A storage stub that records the budget each call was handed. */ +function budgetSpy(deadline: RouteDeadline, stepMs: number) { + const seen: number[] = []; + let elapsed = 0; + const at = () => { + // Each call takes `stepMs`, so the NEXT one starts later. + const shifted = createRouteDeadline(deadline.budgetMs, deadline.startedAt - elapsed); + elapsed += stepMs; + return shifted; + }; + return { + seen, + /** What bucket.ts would compute for a call made at this point. */ + observe: (given: RouteDeadline | undefined) => { + seen.push(storageBudgetMs(given)); + }, + at, + }; +} + +test("verifyStoredCopy hands BOTH its storage calls the same shrinking budget", async () => { + // A budget SMALLER than STORAGE_CALL_MAX_MS, deliberately: with a real + // deadline every call is capped by what the ROUTE has left, and with + // none it gets the full fifteen seconds. A 20s route at t=0 would + // produce 15_000 either way, and the assertion would prove nothing. + const route = createRouteDeadline(9_000); + const spy = budgetSpy(route, 2_000); + + // The size probe, then the download: the two calls this function makes. + await verifyStoredCopy( + "receipts/intake/row-1.v1.png", + "a".repeat(64), + spy.at(), + async (_path, _lister, deadline) => { + spy.observe(deadline); + // A slow probe, so the download that follows it demonstrably has + // LESS of the route's budget left -- which is the whole property. + await new Promise(resolve => setTimeout(resolve, 60)); + return { ok: true, size: 10 }; + }, + async (_path, deadline) => { + spy.observe(deadline); + return { ok: true, bytes: Buffer.from("abcd") }; + }, + ); + + assert.equal(spy.seen.length, 2, "both calls were made"); + for (const budget of spy.seen) { + assert.ok(budget > 0, `a real budget, not an absent one (${budget})`); + assert.ok( + budget <= 9_000, + `capped by what the ROUTE has left, never a fresh ${STORAGE_CALL_MAX_MS}ms (${budget})`, + ); + } + assert.ok(spy.seen[1] < spy.seen[0], "and the second call gets strictly less than the first"); +}); + +test("PRE-FIX CONTROL: with no deadline, every call in the chain gets a fresh 15s", async () => { + // What the callers were doing: passing nothing, one level at a time. + const seen: number[] = []; + await verifyStoredCopy( + "receipts/intake/row-1.v1.png", + "a".repeat(64), + undefined, + async (_path, _lister, deadline) => { + seen.push(storageBudgetMs(deadline)); + return { ok: true, size: 10 }; + }, + async (_path, deadline) => { + seen.push(storageBudgetMs(deadline)); + return { ok: true, bytes: Buffer.from("abcd") }; + }, + ); + assert.deepEqual( + seen, + [STORAGE_CALL_MAX_MS, STORAGE_CALL_MAX_MS], + "two full allowances from one function -- the bug, measured", + ); +}); + +test("EVERY route and cron creates exactly ONE deadline, and threads it", () => { + // The structural half. A handler that creates none hands `undefined` to + // everything below it; one that creates several has no single budget at all. + for (const [rel, budget] of [ + ["src/app/api/receipts/intake/route.ts", /const ROUTE_BUDGET_MS = 2[0-9]_000;/], + ["src/app/api/receipts/intake/start/route.ts", /const ROUTE_BUDGET_MS = 2[0-9]_000;/], + ["src/app/api/receipts/intake/[id]/finalize/route.ts", /const ROUTE_BUDGET_MS = 2[0-9]_000;/], + ] as const) { + const body = readFileSync(path.join(ROOT, rel), "utf8"); + const made = (body.match(/createRouteDeadline\(/g) ?? []).length; + assert.equal(made, 1, `${rel} creates one deadline`); + assert.match(body, budget, `${rel} budgets under the platform ceiling`); + } + + // And the storage entry points REQUIRE it, so no caller can quietly omit it. + const stored = readFileSync(path.join(ROOT, "src/lib/receipt-intake/stored-object.ts"), "utf8"); + for (const fn of ["verifyStoredCopy", "inspectStoredObject", "downloadVerified", "sealAndPublish"]) { + const at = stored.indexOf(`export async function ${fn}(`); + assert.ok(at > 0, `${fn} is exported`); + const sig = stored.slice(at, stored.indexOf("): Promise", at)); + assert.match(sig, /deadline: RouteDeadline \| undefined/, `${fn} takes the deadline`); + assert.ok(!/deadline\?: /.test(sig), `${fn}'s deadline is NOT optional`); + } +}); +// -- The exclusion matrix, against the claim TABLE (round-21 finding 1) ---- +// +// One row per path is the invariant; these are the rules the acquisition +// applies on top of it. Driven through the shipped function rather than +// restated, so a rule that stops being applied fails here. + +/** A tx fake carrying just the claim table and the lock. */ +function claimTx(seed: ClaimRow[] = []) { + const claims = new Map(seed.map(c => [c.storagePath, { ...c }])); + let locks = 0; + const tx = { + $executeRaw: async () => { locks += 1; return 1; }, + receiptObjectClaim: { + findUnique: async ({ where }: { where: { storagePath: string } }) => + claims.get(where.storagePath) ?? null, + upsert: async ( + { where, create, update }: { + where: { storagePath: string }; + create: ClaimRow; + update: Partial; + }, + ) => { + const held = claims.get(where.storagePath); + const next = held ? { ...held, ...update } : { ...create }; + claims.set(where.storagePath, next as ClaimRow); + return next; + }, + update: async ( + { where, data }: { where: { storagePath: string }; data: Partial }, + ) => { + const held = claims.get(where.storagePath); + if (!held) throw new Error(`no claim for ${where.storagePath}`); + const next = { ...held, ...data }; + claims.set(where.storagePath, next); + return next; + }, + deleteMany: async () => ({ count: 0 }), + }, + } as unknown as Prisma.TransactionClient; + return { tx, claims, locks: () => locks }; +} + +test("EXCLUSION: publishing and deleting cannot both hold a live claim", async () => { + const PATH = "receipts/intake/row-1.v1.png"; + const now = new Date("2026-09-03T12:00:00.000Z"); + const live = now.getTime() + 60_000; + const until = new Date(live); + + for (const [held, want, refused] of [ + ["publishing", "deleting", true], + ["deleting", "publishing", true], + // TWO DELETERS MAY NOT SHARE. Both would proceed to remove the same + // object, and the second would delete bytes the first had already + // accounted for -- or, worse, an object a publisher put back between + // them. + ["deleting", "deleting", true], + // TWO PUBLISHERS MAY: the path is content-addressed, so they are + // writing identical bytes and the seal is an upsert. + ["publishing", "publishing", false], + ] as const) { + const w = claimTx([{ storagePath: PATH, token: "held", kind: held, expiresAt: until }]); + const got = await acquireObjectClaim(w.tx, PATH, want, until, now); + assert.equal(got.ok, !refused, `${held} vs ${want}`); + if (refused) assert.equal((got as { heldBy: string }).heldBy, held); + assert.ok(w.locks() >= 1, "and the lock was taken first, either way"); + } +}); + +test("A LAPSED claim is taken over, so a dead holder cannot wedge a path", async () => { + const PATH = "receipts/intake/row-1.v1.png"; + const now = new Date("2026-09-03T12:00:00.000Z"); + const w = claimTx([{ + storagePath: PATH, + token: "dead-holder", + kind: "deleting", + expiresAt: new Date(now.getTime() - 1), + }]); + + const got = await acquireObjectClaim(w.tx, PATH, "publishing", new Date(now.getTime() + 60_000), now); + + assert.equal(got.ok, true, "an expired claim holds nothing"); + assert.equal(w.claims.get(PATH)?.kind, "publishing"); + assert.notEqual( + w.claims.get(PATH)?.token, + "dead-holder", + "and the token changes, so the old holder's own re-read tells it it lost", + ); + assert.equal(w.claims.size, 1, "still one row: the path is the primary key"); +}); +// -- A DELETE MAY NOT OUTLIVE THE CLAIM IT RUNS UNDER (round-22) ----------- +// +// Every expiry in the sweep derived from a `now` captured when the pass +// started. A late item could reach the delete ten seconds later -- two short +// transactions on a loaded pool -- and then spend up to STORAGE_CALL_MAX_MS +// inside the removal itself. A claim measured from the opening instant lapses +// in that window; a publisher takes the path, seals the canonical object, and +// the delete still in flight removes the bytes it just published. The row is +// RECEIVED and points at nothing. + +test("the claim is RENEWED from CURRENT time immediately before the delete", async () => { + const w = world(); + const CANON = "receipts/row-1/v1/late.png"; + w.objects.add(CANON); + w.events.push({ id: "ev-late", status: "pending", detail: JSON.stringify({ storagePath: CANON }) }); + + // The pass opens, and then a long time passes before this item is reached -- + // exactly what a batch of earlier items does to the last one in it. + w.onAfterClaim(() => w.advance(40_000)); + + assert.equal(await retryPendingCleanups(10, () => false, w.sweep), 1, "it still deletes"); + assert.equal(w.objects.has(CANON), false); + + // THE PROPERTY: the claim it ran under expires AFTER the renewal, not after + // the pass's opening instant -- and by more than a storage call can take. + const held = w.claims.get(CANON); + assert.ok(held, "the claim row survives the delete for the sweeper to settle"); + const renewedFor = held.expiresAt.getTime() - w.sweep.now().getTime(); + assert.ok( + renewedFor >= STORAGE_CALL_MAX_MS, + `the claim outlives a full-length delete (${renewedFor}ms vs ${STORAGE_CALL_MAX_MS}ms)`, + ); + assert.equal(DELETE_CLAIM_LEASE_MS, STORAGE_CALL_MAX_MS + 15_000, "cap plus margin"); + assert.ok( + DELETE_CLAIM_LEASE_MS > STORAGE_CALL_MAX_MS, + "the delete's own bound is STRICTLY shorter than the claim it runs under", + ); +}); + +test("PRE-FIX CONTROL: a claim measured from the pass's opening instant has lapsed", () => { + // The arithmetic the old code did, with the finding's own numbers: an item + // that starts just before the 40s soft stop and then spends up to ten + // seconds in its two short transactions on a loaded pool reaches the delete + // fifty seconds after the pass opened. OBJECT_CLAIM_LEASE_MS measured from + // THAT opening instant has ten seconds left -- and the delete it is about + // to start may take fifteen. + const openedAt = new Date("2026-09-03T12:00:00.000Z"); + const reachedAt = new Date(openedAt.getTime() + 50_000); + const staleExpiry = new Date(openedAt.getTime() + OBJECT_CLAIM_LEASE_MS); + const leftForTheDelete = staleExpiry.getTime() - reachedAt.getTime(); + assert.ok( + leftForTheDelete < STORAGE_CALL_MAX_MS, + `a delete could outlive it (${leftForTheDelete}ms left, ${STORAGE_CALL_MAX_MS}ms needed)`, + ); + + // Renewed from CURRENT time, the same moment has the full lease ahead of it. + const renewed = new Date(reachedAt.getTime() + DELETE_CLAIM_LEASE_MS); + assert.ok(renewed.getTime() - reachedAt.getTime() > STORAGE_CALL_MAX_MS); +}); + +test("a renewal is REFUSED once somebody else holds the path", async () => { + // The renewal is a claim check as well as an extension: a sweeper whose + // claim lapsed and was taken over must not extend the new holder's row. + const PATH = "receipts/intake/row-1.v1.png"; + const now = new Date("2026-09-03T12:00:00.000Z"); + const w = claimTx([{ + storagePath: PATH, + token: "somebody-else", + kind: "publishing", + expiresAt: new Date(now.getTime() + 60_000), + }]); + + const ours = await renewObjectClaim( + w.tx, PATH, "deleting", "our-old-token", new Date(now.getTime() + 60_000), now, + ); + assert.equal(ours, false, "not our token, not our kind"); + assert.equal(w.claims.get(PATH)?.token, "somebody-else", "and we did not touch it"); + + // ...nor may an expired claim of our own be renewed: it is gone, and the + // path may already have been taken and released again. + const lapsed = claimTx([{ + storagePath: PATH, + token: "ours", + kind: "deleting", + expiresAt: new Date(now.getTime() - 1), + }]); + assert.equal( + await renewObjectClaim(lapsed.tx, PATH, "deleting", "ours", new Date(now.getTime() + 60_000), now), + false, + "an expired claim cannot be resurrected in place", + ); + + // CONTROL: a live claim of ours renews, and the expiry really moves. + const mine = claimTx([{ + storagePath: PATH, + token: "ours", + kind: "deleting", + expiresAt: new Date(now.getTime() + 1_000), + }]); + const until = new Date(now.getTime() + DELETE_CLAIM_LEASE_MS); + assert.equal(await renewObjectClaim(mine.tx, PATH, "deleting", "ours", until, now), true); + assert.equal(mine.claims.get(PATH)?.expiresAt.getTime(), until.getTime()); + assert.ok(mine.locks() >= 1, "and it took the lock to do it"); +}); + +test("the worker binds the sweep's delete to the INVOCATION's deadline", () => { + // The default dependency passes none, so every delete took a fresh + // STORAGE_CALL_MAX_MS however late in the pass it started -- which is what + // let one outlive its claim in the first place. + const cleanup = readFileSync(path.join(ROOT, "src/lib/receipt-intake/storage-cleanup.ts"), "utf8"); + assert.match(cleanup, /export function liveSweepDepsFor\(deadline: RouteDeadline \| undefined\)/); + assert.match(cleanup, /remove: \(storagePath: string\) => removeReceiptObject\(storagePath, deadline\)/); + + const cron = readFileSync(path.join(ROOT, "src/app/api/cron/receipt-intake-worker/route.ts"), "utf8"); + assert.match(cron, /liveSweepDepsFor\(invocationDeadline\)/, "and the worker passes its own"); +}); diff --git a/tests/receipt-intake-cutover.test.ts b/tests/receipt-intake-cutover.test.ts new file mode 100644 index 000000000..5868b5574 --- /dev/null +++ b/tests/receipt-intake-cutover.test.ts @@ -0,0 +1,288 @@ +/** + * The cutover boundary and the storage-failure classification. + * + * Both are places where getting the answer WRONG loses money rather than + * merely erroring: a mis-parsed boundary retires receipts nobody booked, and a + * mis-classified storage fault declares a present file missing and releases its + * dedup key. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseCutoverBoundary, + CUTOVER_SETTING_KEY, + driveFileIdOf, + triageCutoverRows, + applyCutoverVerdict, + type CutoverCandidate, + type CutoverRow, + type CutoverWriteClient, +} from "../src/lib/receipt-intake/cutover"; + +test("a missing or malformed boundary is null — never epoch", () => { + // The dangerous failure: `new Date(undefined)` style coercion yielding a + // date in 1970 would put the ENTIRE backlog "before the boundary" and + // retire every row, including the ones v1 never booked. + for (const bad of [undefined, null, "", " ", "not-a-date", "yesterday", "2026-13-45"]) { + assert.equal(parseCutoverBoundary(bad as string | null | undefined), null, JSON.stringify(bad)); + } +}); + +test("a real ISO timestamp parses to that instant", () => { + const at = parseCutoverBoundary("2026-08-25T17:30:00.000Z"); + assert.ok(at); + assert.equal(at.toISOString(), "2026-08-25T17:30:00.000Z"); + // Surrounding whitespace is a copy-paste artefact, not a different answer. + assert.equal(parseCutoverBoundary(" 2026-08-25T17:30:00.000Z ")!.toISOString(), "2026-08-25T17:30:00.000Z"); +}); + +test("the setting key is stable — an operator writes this row at the flip", () => { + // Renaming it silently would make every future cutover refuse. + assert.equal(CUTOVER_SETTING_KEY, "cutoverV1StoppedAt"); +}); + +test("ambiguous or naive timestamps are rejected, not silently shifted", () => { + // Each of these parses fine under plain `new Date()`/`Date.parse()`, but + // none of them names one unambiguous instant: a date-only value reads as + // UTC midnight, a naive local value reads in the SERVER's zone, and a + // slash-separated value is locale-ambiguous (US vs. day-first). Any of + // those can shift the boundary by hours, which either retires rows v1 + // never booked or lets a v1-booked row slip through to be double-booked. + for (const ambiguous of [ + "2026-09-01", // date-only — UTC midnight + "2026-09-01T10:00:00", // naive local time, no offset + "2026-09-01T10:00:00.123", // naive local time with fractional seconds + "9/1/2026", // locale-ambiguous + "2026-09-01 10:00:00Z", // space instead of T + "2026-09-01T10:00", // missing seconds + ]) { + assert.equal(parseCutoverBoundary(ambiguous), null, ambiguous); + } +}); + +test("an explicit ±HH:MM offset is accepted and converted to the right instant", () => { + const at = parseCutoverBoundary("2026-08-25T10:30:00-07:00"); + assert.ok(at); + assert.equal(at.toISOString(), "2026-08-25T17:30:00.000Z"); +}); + +// ── The three-way split: evidence outranks the timestamp ─────────────────── + +const BOUNDARY = new Date("2026-08-25T00:00:00.000Z"); +const before = new Date("2026-08-24T12:00:00.000Z"); +const after = new Date("2026-08-26T12:00:00.000Z"); + +function candidate(over: Partial = {}): CutoverCandidate { + return { + id: "row-1", + source: "drive", + sourceRef: "drive:FILE1", + archivedByV1: false, + createdAt: before, + ...over, + }; +} + +test("an AFTER-boundary row the forwarder says v1 archived is RETIRED, never booked", async () => { + // The hole: the candidate query filtered on createdAt first, so a file v1 + // had ALREADY booked but handed over after the flip (a queued send, a + // retry, a slow archive step) never reached the evidence check — it went + // into the requeue and v2 booked a SECOND Purchase. For an email or chat + // row there is no shared identity to collapse that: v2 books under the + // intake UUID, which v1 never saw. + for (const source of ["email", "chat"]) { + const triage = triageCutoverRows( + [candidate({ source, sourceRef: `${source}:msg-1`, archivedByV1: true, createdAt: after })], + BOUNDARY, + new Set(), + ); + assert.deepEqual(triage.evidenced, ["row-1"], source); + assert.deepEqual(triage.unevidenced, [], `${source}: never handed to v2`); + assert.deepEqual(triage.quarantined, [], source); + } +}); + +test("a v1 booked marker also retires an after-boundary row", async () => { + const triage = triageCutoverRows( + [candidate({ createdAt: after })], + BOUNDARY, + new Set(["FILE1"]), + ); + assert.deepEqual(triage.evidenced, ["row-1"]); +}); + +test("only EVIDENCE-FREE rows are judged by the boundary", async () => { + const rows = [ + candidate({ id: "after-nothing", source: "email", sourceRef: "email:m2", createdAt: after }), + candidate({ id: "before-drive", sourceRef: "drive:F2", createdAt: before }), + candidate({ id: "before-email", source: "email", sourceRef: "email:m3", createdAt: before }), + ]; + const triage = triageCutoverRows(rows, BOUNDARY, new Set()); + // After the flip nothing but v2 could have booked it. + assert.deepEqual(triage.unevidenced, ["after-nothing", "before-drive"]); + // Shadow-window, no evidence, no shared identity: a human decides. + assert.deepEqual(triage.quarantined, ["before-email"]); + assert.deepEqual(triage.evidenced, []); +}); + +test("the boundary instant itself counts as AFTER, and every row lands in exactly one bucket", async () => { + const rows = [ + candidate({ id: "at-boundary", source: "chat", sourceRef: "chat:m1", createdAt: BOUNDARY }), + candidate({ id: "evidenced", archivedByV1: true }), + candidate({ id: "quarantine", source: "web", sourceRef: "web:u1" }), + ]; + const triage = triageCutoverRows(rows, BOUNDARY, new Set()); + assert.deepEqual(triage.unevidenced, ["at-boundary"]); + assert.deepEqual(triage.evidenced, ["evidenced"]); + assert.deepEqual(triage.quarantined, ["quarantine"]); + const all = [...triage.evidenced, ...triage.unevidenced, ...triage.quarantined]; + assert.equal(all.length, rows.length, "no row is dropped or counted twice"); +}); + +test("driveFileIdOf only claims a shared identity for a real drive ref", () => { + assert.equal(driveFileIdOf({ source: "drive", sourceRef: "drive:ABC" }), "ABC"); + assert.equal(driveFileIdOf({ source: "email", sourceRef: "drive:ABC" }), null); + assert.equal(driveFileIdOf({ source: "drive", sourceRef: "web:ABC" }), null); +}); + +// ── The cutover WRITES, fenced on the rows they were decided about ───────── + +function row(over: Partial = {}): CutoverRow { + return { + ...candidate(), + state: "READ", + stateReason: null, + dryRun: true, + claimToken: null, + ...over, + }; +} + +/** + * A store whose `updateMany` really evaluates the where clause — the fence IS + * the subject, so a fake that matched on the id alone would report success for + * a row somebody else had already moved, which is precisely the bug. + */ +function store(rows: (CutoverRow | Record)[]) { + const state = { + rows: rows.map(r => ({ ...r } as Record)), + wheres: [] as Record[], + }; + const db: CutoverWriteClient = { + updateMany: async ({ where, data }) => { + state.wheres.push(where); + const { id, ...rest } = where as { id: { in: string[] } } & Record; + const hits = state.rows.filter(r => + id.in.includes(r.id as string) + && Object.entries(rest).every(([k, v]) => r[k] === v || (r[k] == null && v == null))); + for (const hit of hits) Object.assign(hit, data); + return { count: hits.length }; + }, + }; + return { state, db }; +} + +const RETIRE = { state: "SHADOW_DONE", stateReason: "booked-by-v1", nextRetryAt: null }; + +test("A ROW REVIEWED BETWEEN THE SELECT AND THE WRITE KEEPS ITS REVIEWED STATE", async () => { + // The finding: the three cutover updates constrained nothing but `id`. The + // candidates are read in the claim transaction, but READ COMMITTED lets a + // writer that never touches the advisory lock — an admin review, a future + // queue UI, a late completion — move a row in the gap. The verdict then + // landed on a row it was never computed for, and SHADOW_DONE / + // SHADOW_QUARANTINE are terminal. + const observed = row({ id: "reviewed" }); + const { state, db } = store([observed]); + + // The concurrent review, AFTER the triage saw the row and BEFORE the write. + state.rows[0].state = "NEEDS_REVIEW"; + state.rows[0].stateReason = "human-hold"; + + const moved = await applyCutoverVerdict([observed], RETIRE, db); + assert.deepEqual(moved, { moved: 0, skippedMoved: 1 }, "the verdict is dropped, and counted"); + assert.equal(state.rows[0].state, "NEEDS_REVIEW", "the human's decision survives"); + assert.equal(state.rows[0].stateReason, "human-hold"); +}); + +test("an UNTOUCHED row still gets its verdict", async () => { + // The control: without it, a CAS that never matches anything would pass the + // test above while breaking the entire cutover. + const observed = row({ id: "quiet" }); + const { state, db } = store([observed]); + const moved = await applyCutoverVerdict([observed], RETIRE, db); + assert.deepEqual(moved, { moved: 1, skippedMoved: 0 }); + assert.equal(state.rows[0].state, "SHADOW_DONE"); + assert.equal(state.rows[0].stateReason, "booked-by-v1"); +}); + +test("a row CLAIMED between the select and the write is skipped, not overwritten", async () => { + // A worker that owns the row is mid-flight on it. Retiring it under the + // claim would strand a pass writing results into a terminal state. + const observed = row({ id: "claimed" }); + const { state, db } = store([observed]); + state.rows[0].claimToken = "tok-9"; + const moved = await applyCutoverVerdict([observed], RETIRE, db); + assert.deepEqual(moved, { moved: 0, skippedMoved: 1 }); + assert.equal(state.rows[0].state, "READ"); +}); + +test("a row taken off the shadow switch mid-pass is not handed to v2 twice", async () => { + // `dryRun` is pinned too: the requeue writes `dryRun: false`, and a row + // something else already flipped is no longer the row that was triaged. + const observed = row({ id: "live-now" }); + const { state, db } = store([observed]); + state.rows[0].dryRun = false; + const moved = await applyCutoverVerdict([observed], { dryRun: false, nextRetryAt: null }, db); + assert.deepEqual(moved, { moved: 0, skippedMoved: 1 }); +}); + +test("the CAS carries the WHOLE parked predicate plus the observed evidence", async () => { + const observed = row({ id: "r1", state: "BOOKING", stateReason: "qbo-fault:6240" }); + const { state, db } = store([observed]); + await applyCutoverVerdict([observed], RETIRE, db); + assert.deepEqual(state.wheres, [{ + id: { in: ["r1"] }, + dryRun: true, + state: "BOOKING", + stateReason: "qbo-fault:6240", + claimToken: null, + }]); +}); + +test("a STALE claim is pinned, not demanded away — the row still reaches its verdict", async () => { + // A shadow-parked row is excluded from the claim entirely, so a token on it + // on the live pass is a leftover from a pass that died during the shadow + // week. Requiring `claimToken: null` would hide the row from the cutover + // FOREVER: nothing can re-claim it to release the token, so it would never + // be retired, requeued or quarantined, and nobody would be told. + const observed = row({ id: "stale", claimToken: "dead-tok" }); + const { state, db } = store([observed]); + const moved = await applyCutoverVerdict([observed], RETIRE, db); + assert.deepEqual(moved, { moved: 1, skippedMoved: 0 }); + assert.equal(state.rows[0].state, "SHADOW_DONE"); + assert.equal(state.wheres[0].claimToken, "dead-tok", "pinned at what was observed"); +}); + +test("rows are grouped by their OBSERVED state, so one verdict cannot smear another's", async () => { + // Grouping is a round-trip optimisation, not a loosening: two rows parked + // for different reasons must not be written under one another's fence. + const a = row({ id: "a", state: "READ", stateReason: null }); + const b = row({ id: "b", state: "READ", stateReason: null }); + const c = row({ id: "c", state: "BOOKING", stateReason: "qbo-fault:6240" }); + const { state, db } = store([a, b, c]); + // `c` moves; `a` and `b` do not. + state.rows[2].stateReason = "max-retries"; + + const moved = await applyCutoverVerdict([a, b, c], RETIRE, db); + assert.deepEqual(moved, { moved: 2, skippedMoved: 1 }); + assert.equal(state.wheres.length, 2, "one statement per distinct observed state"); + assert.equal(state.rows[0].state, "SHADOW_DONE"); + assert.equal(state.rows[1].state, "SHADOW_DONE"); + assert.equal(state.rows[2].state, "BOOKING", "the row that moved is untouched"); +}); + +test("no rows is no statements", async () => { + const { state, db } = store([]); + assert.deepEqual(await applyCutoverVerdict([], RETIRE, db), { moved: 0, skippedMoved: 0 }); + assert.deepEqual(state.wheres, []); +}); diff --git a/tests/receipt-intake-keys.test.ts b/tests/receipt-intake-keys.test.ts new file mode 100644 index 000000000..e4cb2622b --- /dev/null +++ b/tests/receipt-intake-keys.test.ts @@ -0,0 +1,204 @@ +/** + * Dedup-key fixtures, taken from REAL filenames in the August 2026 archive + * (I:\My Drive\Expenses\Processed Receipts\2026\August). The v1 Apps Script + * built those names from the same cleaned fields the v2 reader produces, so + * each name is a recorded (project, date, vendor, ref, total) tuple — which + * makes them the only fixtures that can prove the port AGREES with the + * pipeline that is still in production. + * + * A key that changes here is a shadow-week mismatch, not a refactor. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + canonicalVendor, + cleanMoney, + dedupKeys, + isValidDate, + normalizeDateStr, + refLooksReal, + sanitize, +} from "../src/lib/receipt-intake/keys"; + +/** One archive filename, split back into the fields v1 wrote into it. */ +interface Fixture { + file: string; + vendor: string; + date: string; + invoice: string; + total: string; + strong: string | null; + weak: string; +} + +const FIXTURES: Fixture[] = [ + { + file: "Berg_ADU_2026-08-03_Lowes_82766_$364.98", + vendor: "Lowes", date: "2026-08-03", invoice: "82766", total: "364.98", + strong: "2026-08-03|82766", + weak: "lowes|2026-08-03|364.98|amt", + }, + { + // The alias list collapses this vendor onto the row above's token, which + // is the whole point: ONE store, several spellings across its own formats. + file: "Berg_ADU_2026-08-03_Lowes_Home_Improvement_99908_$277.19", + vendor: "Lowes Home Improvement", date: "2026-08-03", invoice: "99908", total: "277.19", + strong: "2026-08-03|99908", + weak: "lowes|2026-08-03|277.19|amt", + }, + { + // Ref "12" is under three characters: too short to identify anything, so + // the strong key is WITHHELD and the weak net handles it. + file: "Berg_ADU_2026-08-04_WINLOCK_HARDWARE_12_$14.50", + vendor: "WINLOCK HARDWARE", date: "2026-08-04", invoice: "12", total: "14.50", + strong: null, + weak: "winlockhardware|2026-08-04|14.50|amt", + }, + { + file: "Berg_ADU_2026-08-04_WINLOCK_HARDWARE_4_$16.17", + vendor: "WINLOCK HARDWARE", date: "2026-08-04", invoice: "4", total: "16.17", + strong: null, + weak: "winlockhardware|2026-08-04|16.17|amt", + }, + { + // A non-alias vendor keeps its own collapsed token. + file: "Berg_ADU_2026-08-07_CRC_-_WEST_VAN_260807091421373F2A9_$91.50", + vendor: "CRC - WEST VAN", date: "2026-08-07", invoice: "260807091421373F2A9", total: "91.50", + strong: "2026-08-07|260807091421373f2a9", + weak: "crcwestvan|2026-08-07|91.50|amt", + }, + { + file: "Berg_ADU_2026-08-09_Amazon.com_113-9992333-7801840_$248.27", + vendor: "Amazon.com", date: "2026-08-09", invoice: "113-9992333-7801840", total: "248.27", + strong: "2026-08-09|113-9992333-7801840", + weak: "amazon|2026-08-09|248.27|amt", + }, + { + // "NoInv" is the AI saying it found no number — a placeholder, never an identity. + file: "Berg_ADU_2026-08-10_Grover_Electric_Plumbing_Supply_NoInv_$22.57", + vendor: "Grover Electric Plumbing Supply", date: "2026-08-10", invoice: "", total: "22.57", + strong: null, + weak: "groverelectricplumbingsupply|2026-08-10|22.57|amt", + }, + { + file: "Berg_ADU_2026-08-14_LOWES_HOME_CENTERS_LLC_58302_$304.23", + vendor: "LOWES HOME CENTERS LLC", date: "2026-08-14", invoice: "58302", total: "304.23", + strong: "2026-08-14|58302", + weak: "lowes|2026-08-14|304.23|amt", + }, +]; + +test("August archive fixtures produce the v1 dedup keys", () => { + for (const f of FIXTURES) { + const keys = dedupKeys({ + docType: "receipt", + vendor: f.vendor, + date: f.date, + invoice: f.invoice, + checkNumber: "", + totalAmount: f.total, + fallbackDateStr: "2099-01-01", // must never be reached: every fixture has a real date + }); + assert.equal(keys.strong, f.strong, `${f.file} strong`); + assert.equal(keys.weak, f.weak, `${f.file} weak`); + assert.equal(keys.dateStr, f.date, `${f.file} date`); + assert.equal(keys.amount, f.total, `${f.file} amount`); + } +}); + +test("an unreadable date falls back to the intake row's own date", () => { + const keys = dedupKeys({ + docType: "receipt", + vendor: "Lowes", + date: "", // the model returns "" rather than guessing + invoice: "82766", + totalAmount: "364.98", + fallbackDateStr: "2026-08-20", + }); + assert.equal(keys.dateStr, "2026-08-20"); + // The strong key needs a date READ OFF THE DOCUMENT. A fallback date is our + // guess, and two unrelated receipts uploaded the same day must not collide + // on it. + assert.equal(keys.strong, null); + assert.equal(keys.weak, "lowes|2026-08-20|364.98|amt"); +}); + +test("an invalid calendar date is not a date", () => { + for (const bad of ["2026-13-05", "2026-02-30", "not-a-date", ""]) { + assert.equal(isValidDate(bad), false, bad); + } + assert.equal(isValidDate("2026-08-03"), true); + assert.equal(normalizeDateStr("2026-06-10T00:00:00Z"), "2026-06-10"); + assert.equal(normalizeDateStr(" 2026-06-10 "), "2026-06-10"); + assert.equal(normalizeDateStr("June 10"), ""); +}); + +test("checks key on the check number, not the invoice", () => { + const keys = dedupKeys({ + docType: "check", + vendor: "Richard Lord", + date: "2026-08-05", + invoice: "ignored", + checkNumber: "4178", + totalAmount: "1,200.00", + fallbackDateStr: "2099-01-01", + }); + assert.equal(keys.ref, "Check4178"); + assert.equal(keys.strong, "2026-08-05|check4178"); + assert.equal(keys.amount, "1200.00"); +}); + +test("a check with no readable number gets no strong key", () => { + const keys = dedupKeys({ + docType: "check", + vendor: "Someone", + date: "2026-08-05", + checkNumber: "", + totalAmount: "50.00", + fallbackDateStr: "2099-01-01", + }); + assert.equal(keys.ref, "CheckNoNum"); + assert.equal(keys.strong, null); +}); + +test("placeholder refs are refused; real ones that merely look odd are not", () => { + // :1571–1580 — the padded forms are exactly what the AI emits when it can't + // read a number, and they used to become the SHARED key of every unrelated + // receipt that day. + assert.equal(refLooksReal("NA 000"), false); + assert.equal(refLooksReal("0000"), false); + assert.equal(refLooksReal("Unknown 0000"), false); + assert.equal(refLooksReal("N/A"), false); + assert.equal(refLooksReal("NoInv"), false); + assert.equal(refLooksReal("12"), false); + assert.equal(refLooksReal("ABC"), false); + assert.equal(refLooksReal("1111"), false); + // "INV"/"ORDER"/"REF" are deliberately NOT placeholders — they prefix real numbers. + assert.equal(refLooksReal("INV-95870"), true); + assert.equal(refLooksReal("82766"), true); + assert.equal(refLooksReal("113-9992333-7801840"), true); +}); + +test("cleanMoney handles currency, commas and accounting negatives", () => { + assert.equal(cleanMoney("$1,234.56"), "1234.56"); + assert.equal(cleanMoney("-12.50"), "-12.50"); + assert.equal(cleanMoney("(123.45)"), "-123.45"); + assert.equal(cleanMoney(""), "0.00"); + assert.equal(cleanMoney("not a number"), "0.00"); + assert.equal(cleanMoney(null), "0.00"); +}); + +test("sanitize drops punctuation and collapses whitespace, like the archive names", () => { + assert.equal(sanitize("Lowe's Home Improvement"), "Lowes_Home_Improvement"); + assert.equal(sanitize("CRC - WEST VAN"), "CRC_-_WEST_VAN"); + assert.equal(sanitize(""), ""); +}); + +test("canonicalVendor collapses a chain's spellings and keeps others intact", () => { + for (const spelling of ["LOWES", "Lowe's Home Improvement", "Lowes Home Centers LLC S1632MC3"]) { + assert.equal(canonicalVendor(spelling), "lowes", spelling); + } + assert.equal(canonicalVendor("Amazon.com"), "amazon"); + assert.equal(canonicalVendor("CRC - WEST VAN"), "crcwestvan"); + assert.equal(canonicalVendor("Grover Electric Plumbing Supply"), "groverelectricplumbingsupply"); +}); diff --git a/tests/receipt-intake-late-fields.test.ts b/tests/receipt-intake-late-fields.test.ts new file mode 100644 index 000000000..1d0257482 --- /dev/null +++ b/tests/receipt-intake-late-fields.test.ts @@ -0,0 +1,397 @@ +/** + * Late job/phase assignment, and the races around it. + * + * A late field is a write to a row somebody else may be holding. The read that + * decides whether the write is allowed and the write itself are two round trips + * to Postgres, and every interesting bug lives in the gap: the worker claims, + * the state moves, a second caller writes a DIFFERENT project. A CAS that + * simply reports "busy" on a lost race is not enough — the same lost CAS also + * means "somebody already wrote exactly this", and answering 409 there makes a + * correct retry loop forever. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + authorizeEffectiveProject, + authorizePhase, + mergeCapturedFields, + reconcileLateFields, + type Denial, + type LateFieldRow, + type LateFieldsDeps, +} from "../src/lib/receipt-intake/late-fields"; + +function row(over: Partial = {}): LateFieldRow { + return { costCodeId: null, projectId: null, state: "RECEIVED", claimToken: null, ...over }; +} + +interface Trace { + deps: LateFieldsDeps; + applied: Record[]; + authorized: (string | null)[]; +} + +/** `reads` is consumed one per call, so a race can be scripted precisely. */ +function deps(reads: LateFieldRow[], count: number, denial: Denial | null = null): Trace { + const t: Trace = { applied: [], authorized: [], deps: null as unknown as LateFieldsDeps }; + let i = 0; + t.deps = { + read: async () => reads[Math.min(i++, reads.length - 1)] ?? null, + applyIfNull: async (_id, _state, toApply) => { t.applied.push(toApply); return count; }, + authorize: async projectId => { t.authorized.push(projectId); return denial; }, + }; + return t; +} + +test("an un-routed row with empty fields takes the values", async () => { + const t = deps([row()], 1); + assert.equal(await reconcileLateFields("r1", { projectId: "p1", costCodeId: "c1" }, t.deps), null); + assert.deepEqual(t.applied, [{ projectId: "p1", costCodeId: "c1" }]); +}); + +test("a routed row refuses a DIFFERENT project and never writes", async () => { + // Past RECEIVED the dedup keys, the phase suggestion and possibly a Purchase + // were all derived from the project the row had. Changing it now does not + // re-derive any of that. + const t = deps([row({ state: "READ", projectId: "p1" })], 1); + const denial = await reconcileLateFields("r1", { projectId: "p2" }, t.deps); + assert.equal(denial?.status, 409); + assert.equal(denial?.body.error, "late-fields-too-late"); + assert.deepEqual(t.applied, [], "no write was attempted"); +}); + +test("a routed row accepts a repeat of what it already holds", async () => { + // The client's retry after a lost response carries the same fields. That is + // not a conflict, and answering 409 would make a correct client give up. + const t = deps([row({ state: "BOOKED", projectId: "p1" })], 1); + assert.equal(await reconcileLateFields("r1", { projectId: "p1" }, t.deps), null); +}); + +// ── the races ─────────────────────────────────────────────────────────────── + +test("STATE-TRANSITION RACE: the row moves between the read and the write", async () => { + // Read says RECEIVED/unclaimed, so the write is allowed. By the time it + // runs a worker has claimed and read the row, and the CAS matches nothing. + // The persisted project is NOT what was supplied, so this is a real 409 — + // and a retryable one, because a claim is transient. + const t = deps( + [row(), row({ state: "READ", projectId: null, claimToken: "tok-9" })], + 0, + ); + const denial = await reconcileLateFields("r1", { projectId: "p1" }, t.deps); + assert.equal(denial?.status, 409); + assert.equal(denial?.body.error, "late-fields-busy"); + assert.equal(denial?.body.state, "READ"); + assert.equal(denial?.body.retryable, true, "a claim clears on its own; the client should retry"); + assert.deepEqual(t.authorized, [], "a row that does not hold our values is not re-authorized"); +}); + +test("a lost CAS whose row holds EXACTLY what was supplied is a success", async () => { + // Two callers finalized the same row with the same fields. One won. The + // loser must not be told 409 — nothing is wrong and nothing is left to do. + const t = deps([row(), row({ projectId: "p1" })], 0); + assert.equal(await reconcileLateFields("r1", { projectId: "p1" }, t.deps), null); + assert.deepEqual(t.authorized, ["p1"], "still re-authorized against the persisted project"); +}); + +test("CONCURRENT PROJECT CHANGE: the phase is re-authorized against the NEW project", async () => { + // The phase was authorized against the project the row had at read time. A + // concurrent write moved the row to a different job, and our cost code is + // not one of ITS phases. Accepting it here would file the receipt against a + // phase of another job — the exact thing the first check exists to stop. + const denied: Denial = { status: 400, body: { ok: false, error: "cost-code-not-a-phase" } }; + const t = deps([row({ projectId: "p1" }), row({ projectId: "p2", costCodeId: "c1" })], 0, denied); + const result = await reconcileLateFields("r1", { costCodeId: "c1" }, t.deps); + assert.deepEqual(t.authorized, ["p2"], "against the project the row carries NOW, not p1"); + assert.equal(result?.status, 400); + assert.equal(result?.body.error, "cost-code-not-a-phase"); +}); + +test("a row that vanished under the write is a non-retryable conflict", async () => { + const t = deps([row(), null as unknown as LateFieldRow], 0); + const denial = await reconcileLateFields("r1", { projectId: "p1" }, t.deps); + assert.equal(denial?.body.state, "gone"); + assert.equal(denial?.body.retryable, false); +}); + +test("a conflicting stored value is refused before any write", async () => { + const t = deps([row({ projectId: "p1" })], 1); + const denial = await reconcileLateFields("r1", { projectId: "p2" }, t.deps); + assert.equal(denial?.body.error, "late-fields-conflict"); + assert.deepEqual(t.applied, []); +}); + +test("no late fields means no reads and no writes at all", async () => { + const t = deps([row()], 1); + assert.equal(await reconcileLateFields("r1", {}, t.deps), null); + assert.deepEqual(t.applied, []); +}); + +// ── The phase gate: /start is the only place a start-time phase is checked ── + +/** A job with exactly one phase of its own. Anything else belongs elsewhere. */ +const phasesOf: Record = { "proj-a": ["cc-a"], "proj-b": ["cc-b"] }; +const isCostCodeAllowed = async (projectId: string, costCodeId: string) => + (phasesOf[projectId] ?? []).includes(costCodeId); + +test("a phase from ANOTHER job is refused at /start, before a row exists", async () => { + const denial = await authorizePhase("proj-a", "cc-b", isCostCodeAllowed); + assert.equal(denial?.status, 400); + assert.equal(denial?.body.error, "cost-code-not-a-phase"); + assert.equal(denial?.body.projectId, "proj-a"); +}); + +test("a phase with no job at all is refused: it is not meaningful", async () => { + const denial = await authorizePhase(null, "cc-a", isCostCodeAllowed); + assert.equal(denial?.status, 400); + assert.equal(denial?.body.error, "cost-code-without-project"); +}); + +test("the job's own phase passes, and no phase at all is not a lookup", async () => { + assert.equal(await authorizePhase("proj-a", "cc-a", isCostCodeAllowed), null); + let looked = 0; + assert.equal( + await authorizePhase("proj-a", null, async () => { looked++; return true; }), + null, + ); + assert.equal(looked, 0); +}); + +test("REGRESSION: a cross-project phase cannot survive by being OMITTED at finalize", async () => { + // The hole this closes. /start stored a caller-supplied costCodeId + // unchecked, and /finalize only authorizes the fields the FINALIZE call + // carries — so a client that sent the phase at /start and then finalized + // with an empty body got a published row holding a phase from another job, + // having passed no check anywhere. The Expense inherits it and the other + // job's variance report reads overspend on a line nobody budgeted. + // + // Step 1: finalize with no late fields runs NO authorization. That is + // correct — there is nothing to authorize — and it is exactly why /start + // has to be the gate. + let authorizations = 0; + const finalize = deps([row({ projectId: "proj-a", costCodeId: "cc-b" })], 1); + const counted: LateFieldsDeps = { + ...finalize.deps, + authorize: async projectId => { authorizations++; return finalize.deps.authorize(projectId); }, + }; + assert.equal(await reconcileLateFields("r1", {}, counted), null); + assert.equal(authorizations, 0, "finalize never re-checks a phase it was not sent"); + assert.deepEqual(finalize.applied, [], "and writes nothing"); + + // Step 2: so the same payload must be refused at /start, where the row + // would otherwise be created holding it. + const atStart = await authorizePhase("proj-a", "cc-b", isCostCodeAllowed); + assert.equal(atStart?.status, 400, "the row is never created"); + assert.equal(atStart?.body.error, "cost-code-not-a-phase"); +}); + +test("both intake entry points call the SAME phase gate", () => { + // Two copies of this rule is how they drift, and a route that skips it is + // this whole regression again. + const routes = [ + "src/app/api/receipts/intake/start/route.ts", + "src/app/api/receipts/intake/route.ts", + "src/app/api/receipts/intake/[id]/finalize/route.ts", + ]; + for (const rel of routes) { + const source = readFileSync(path.join(__dirname, "..", rel), "utf8"); + assert.match(source, /authorizePhase\(/, `${rel} does not use the shared gate`); + // CALLING it is not the same as OBEYING it: the denial has to end the + // request, or the row is created with the phase anyway. + assert.match( + source, + /if \(badPhase\) return NextResponse\.json\(badPhase\.body, \{ status: badPhase\.status \}\);|return await authorizePhase\(/, + `${rel} does not return the denial`, + ); + assert.ok( + !/error: "cost-code-not-a-phase"/.test(source), + `${rel} carries its own copy of the rule`, + ); + } +}); + +// ── Initial publication is bound by the same rules (Phase 3 gate) ─────────── + +const FINALIZE = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", +); + +test("publishing FILLS IN a captured field that is null", () => { + const merged = mergeCapturedFields({ projectId: null, costCodeId: null }, { projectId: "proj-a" }); + assert.ok(!("status" in merged)); + assert.deepEqual(merged.apply, { projectId: "proj-a" }); + assert.deepEqual(merged.resulting, { projectId: "proj-a", costCodeId: null }); +}); + +test("publishing NEVER overwrites a captured job", () => { + // The hole: initial publication spread the finalize's fields straight over + // the row, so a different job sent at finalize simply won. + const merged = mergeCapturedFields({ projectId: "proj-a", costCodeId: null }, { projectId: "proj-b" }); + assert.ok("status" in merged); + assert.equal(merged.status, 409); + assert.equal(merged.body.error, "late-fields-conflict"); +}); + +test("publishing NEVER overwrites an existing TAX answer", () => { + // installedAtCustomer decides how the purchase is taxed. Silently replacing + // the answer captured at /start is a wrong number in the books, not a + // mislabel — and the field is covered here before the column even exists. + const captured = { projectId: "proj-a", costCodeId: null, installedAtCustomer: true }; + const flipped = mergeCapturedFields(captured, { installedAtCustomer: false } as never); + assert.ok("status" in flipped); + assert.equal(flipped.status, 409); + assert.deepEqual((flipped.body.fields as Record).installedAtCustomer, { + stored: true, supplied: false, + }); + + // The same answer again is a retry, not a conflict. + const same = mergeCapturedFields(captured, { installedAtCustomer: true } as never); + assert.ok(!("status" in same)); + assert.deepEqual(same.apply, {}, "nothing to write"); + + // And an UNANSWERED one is still filled in. + const first = mergeCapturedFields( + { projectId: "proj-a", costCodeId: null, installedAtCustomer: null }, + { installedAtCustomer: true } as never, + ); + assert.ok(!("status" in first)); + assert.deepEqual(first.apply, { installedAtCustomer: true } as never); +}); + +test("A/B: a late job with a phase captured for ANOTHER job is refused", async () => { + // The pair the row would END UP holding is what has to be valid. Neither + // half is wrong on its own: proj-b is a job the caller may reach, and cc-a + // was authorized when it was captured — against job A. + const merged = mergeCapturedFields({ projectId: null, costCodeId: "cc-a" }, { projectId: "proj-b" }); + assert.ok(!("status" in merged)); + assert.deepEqual(merged.resulting, { projectId: "proj-b", costCodeId: "cc-a" }); + assert.deepEqual(merged.from, { projectId: "late", costCodeId: "captured" }, + "half captured, half late — a pair only the merge created"); + + const denial = await authorizePhase(merged.resulting.projectId, merged.resulting.costCodeId, isCostCodeAllowed); + assert.equal(denial?.body.error, "cost-code-not-a-phase"); + + // A phase that IS valid for the late job goes through: the rule is about + // the tuple, not about mixing sources. + const ok = mergeCapturedFields({ projectId: null, costCodeId: "cc-b" }, { projectId: "proj-b" }); + assert.ok(!("status" in ok)); + assert.equal(await authorizePhase(ok.resulting.projectId, ok.resulting.costCodeId, isCostCodeAllowed), null); +}); + +test("the publish CAS covers EVERY captured field, not just the written ones", () => { + // A concurrent writer that filled in the phase we were going to leave alone + // invalidates the tuple this publish validated, so it must lose. + const merged = mergeCapturedFields( + { projectId: null, costCodeId: null, installedAtCustomer: null }, + { projectId: "proj-a" }, + ); + assert.ok(!("status" in merged)); + assert.deepEqual(merged.guard, { projectId: null, costCodeId: null, installedAtCustomer: null }); + assert.deepEqual(merged.apply, { projectId: "proj-a" }, "but only one field is written"); +}); + +test("the publishing UPDATE spreads the merge, never the raw late fields", () => { + const commit = FINALIZE.slice(FINALIZE.indexOf("const outcome = await sealAndPublish")); + const body = commit.slice(0, commit.indexOf("dropUpload:")); + assert.match(body, /\.\.\.merged\.guard/, "CAS over the captured values"); + assert.match(body, /\.\.\.merged\.apply/, "and only the fields that change"); + assert.ok(!/\.\.\.lateFields/.test(body), "the blind spread is gone"); +}); + +test("a mixed tuple is a 409, and a caller's own bad pair stays a 400", () => { + const branch = FINALIZE.slice(FINALIZE.indexOf("const badTuple = await authorizePhase")); + const body = branch.slice(0, branch.indexOf("// ONE shared seal-and-publish")); + assert.match(body, /captured-phase-conflict/); + assert.match(body, /status: 409/); + assert.match(body, /status: badTuple\.status/, "the un-mixed case keeps its own status"); +}); + +test("losing the publish CAS on a STAGING row is a conflict, NOT alreadyFinalized", () => { + // Nothing was published. Telling the client it was finalized is a lie it + // acts on: the forwarders drop their copy of a receipt they are told we hold. + const branch = FINALIZE.slice(FINALIZE.indexOf("if (!outcome.published)")); + const body = branch.slice(0, branch.indexOf("alreadyFinalized")); + assert.match(body, /current\.state !== "STAGING"/); + assert.match(body, /publish-conflict/); + assert.match(body, /status: 409/); +}); + +test("losing the publish CAS demands POSITIVE evidence another publish landed THIS content, not just a non-STAGING state", () => { + // A concurrent /start rearm can move a recoverable NEEDS_REVIEW row onto a + // fresh upload lease without ever publishing it — "state !== STAGING" alone + // cannot tell that apart from a genuine second publisher. Only the row's + // canonical path (named after id+sha+mime, and writable ONLY by a + // successful sealAndPublish commit) landing on exactly what THIS call + // itself verified is proof. + const branch = FINALIZE.slice(FINALIZE.indexOf("if (!outcome.published)")); + const body = branch.slice(0, branch.indexOf("alreadyFinalized")); + assert.match(body, /current\.storagePath === outcome\.canonicalPath/); + assert.match(body, /current\.fileSha256 === fileSha256/); + assert.match(body, /positivelyPublished/); +}); + +// ── Revocation has to bite on the row's OWN project (Phase 3 gate) ────────── + +test("a revoked user is refused on the project the ROW already holds", async () => { + // The hole: access was only re-checked when the request supplied a project. + // A user whose access to a job was revoked could still finalize — publish — + // their existing row on that job, and still attach a phase to it, simply by + // not mentioning the project the row already had. + const looked: string[] = []; + const denial = await authorizeEffectiveProject("proj-a", null, async id => { + looked.push(id); + return false; + }); + assert.deepEqual(looked, ["proj-a"], "the STORED project is what gets checked"); + assert.equal(denial?.status, 403); + assert.equal(denial?.body.error, "project-forbidden"); + assert.equal(denial?.body.projectId, "proj-a"); +}); + +test("a user who still has access passes, and a supplied project wins", async () => { + assert.equal(await authorizeEffectiveProject("proj-a", null, async () => true), null); + + // The effective project is what the row will HOLD: a supplied one replaces + // the stored one, so that is the one to authorize. + const looked: string[] = []; + assert.equal( + await authorizeEffectiveProject("proj-a", "proj-b", async id => { looked.push(id); return true; }), + null, + ); + assert.deepEqual(looked, ["proj-b"]); +}); + +test("no project either way is nothing to authorize — and no lookup", async () => { + let looked = 0; + assert.equal( + await authorizeEffectiveProject(null, null, async () => { looked++; return false; }), + null, + ); + assert.equal(looked, 0, "there is no job to be revoked from"); +}); + +test("finalize authorizes the effective project on EVERY session call, before any write", () => { + const finalize = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + // Unconditional for a session caller — NOT gated on the request carrying a + // project, which is exactly what let a revoked user through. + assert.match( + finalize, + /if \(auth\.via === "session"\) \{\s*\r?\n\s*const forbidden = await authorizeEffectiveProject\(/, + ); + assert.ok( + !/if \(lateFields\.projectId && auth\.via === "session"\)/.test(finalize), + "the supplied-project-only check is gone", + ); + // And it runs before anything can be written or published. + const gate = finalize.indexOf("const denied = await authorizeFinalization(auth, row.projectId"); + assert.notEqual(gate, -1); + for (const write of ["await applyLateFields(", "await sealAndPublish(", "rejectRowAndQueueCleanup("]) { + assert.ok(gate < finalize.indexOf(write), `the gate precedes ${write}`); + } +}); diff --git a/tests/receipt-intake-lease-fence.test.ts b/tests/receipt-intake-lease-fence.test.ts new file mode 100644 index 000000000..d0585e1be --- /dev/null +++ b/tests/receipt-intake-lease-fence.test.ts @@ -0,0 +1,508 @@ +/** + * THE TRIPWIRE: one builder for every lease-bearing CAS. + * + * A ReceiptIntake row that carries an upload lease can be moved by several + * different writers, and each of them has to fence on the SAME identity — + * state, reason, claim, `uploadLeaseVersion`, `uploadLeaseNonce` and + * `uploadUrlExpiresAt`. The nonce and the expiry are the load-bearing half: + * `reuseLiveLease` reissues a working signed URL over the same path at the same + * version, moving ONLY those two columns, so a fence built from state + version + * still matches a row somebody has just re-leased. + * + * That has now been found three rounds running, in a different writer each + * time — the /finalize publish, the /finalize reject, the /start resume, and + * three separate writes in the stale-STAGING sweeper. Every one of them was a + * hand-rolled `where` listing part of the identity. Fixing them one at a time + * does not stop the next one, because nothing makes the omission visible. + * + * So this test reads the source of every such file, finds EVERY `update` / + * `updateMany` / `delete` / `deleteMany` call in them, and fails when a call's + * arguments mention `uploadLeaseVersion` without going through `leaseFence(`. + * Its value is entirely in failing for the NEXT writer somebody adds. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(__dirname, ".."); + +/** + * The text of one top-level function, EOL-AGNOSTIC. + * + * `search(/\n\}\n/)` returns -1 on a CRLF file -- the bytes there are + * `\r\n}\r\n` -- and `slice(0, -1)` then quietly hands back the REST OF THE + * FILE. Every assertion scoped to one function silently becomes unscoped, so a + * NEGATIVE check (`this section calls nothing external`) starts reading every + * function after it and fails for a reason that has nothing to do with its + * subject; a positive one passes on text it was never meant to see. Git's + * autocrlf makes that a property of who cloned the repo, not of the code. + */ +function functionBody(source: string, declaration: string): string { + const from = source.indexOf(declaration); + assert.notEqual(from, -1, `not found: ${declaration}`); + const rest = source.slice(from); + const end = rest.search(/\r?\n\}\r?\n/); + assert.notEqual(end, -1, `no closing brace found for ${declaration}`); + return rest.slice(0, end); +} + + +/** + * Every file that may move a lease-bearing row. Listed explicitly rather than + * globbed: a new file that writes these rows should have to be added here on + * purpose, and that addition is the moment somebody reads this rule. + */ +const LEASE_WRITERS = [ + "src/app/api/receipts/intake/start/route.ts", + "src/app/api/receipts/intake/[id]/finalize/route.ts", + "src/app/api/receipts/intake/route.ts", + "src/lib/receipt-intake/stored-object.ts", + "src/lib/receipt-intake/storage-cleanup.ts", + "src/lib/receipt-intake/upload-lease.ts", + "src/app/api/cron/receipt-intake-worker/route.ts", +]; + +const source = (rel: string) => readFileSync(path.join(ROOT, rel), "utf8"); + +/** + * The balanced argument text of every mutating Prisma call in one file. + * + * Balanced, not regex-to-the-next-brace: these arguments nest objects several + * levels deep, and a naive match stops inside the first `data: { ... }` — which + * is exactly where a missed `where` would hide. + */ +function mutatingCalls(src: string): { name: string; args: string; at: number }[] { + const calls: { name: string; args: string; at: number }[] = []; + const opener = /\.(update|updateMany|delete|deleteMany)\(/g; + let m: RegExpExecArray | null; + while ((m = opener.exec(src))) { + const from = m.index + m[0].length; + let depth = 1; + let i = from; + for (; i < src.length && depth > 0; i++) { + const c = src[i]; + if (c === "(") depth++; + else if (c === ")") depth--; + } + calls.push({ name: m[1], args: src.slice(from, i - 1), at: m.index }); + } + return calls; +} + +test("the extractor really is balanced — the control", () => { + // Without this the tripwire could quietly stop reading at the first nested + // brace and pass every file by seeing nothing at all. + const sample = [ + "await tx.receiptIntake.updateMany({", + " where: { id, ...leaseFence(row) },", + " data: { nested: { deeper: (1 + 2) }, uploadLeaseVersion: 3 },", + "});", + "after();", + ].join("\n"); + const [call] = mutatingCalls(sample); + assert.equal(call.name, "updateMany"); + assert.match(call.args, /leaseFence\(row\)/); + assert.match(call.args, /deeper/, "it did not stop at the first nested object"); + assert.ok(!call.args.includes("after()"), "and it did not run past the call either"); + // Two calls in one file are found separately. + assert.equal(mutatingCalls("a.update({x:1}); b.deleteMany({y:2});").length, 2); +}); + +test("EVERY lease-bearing write builds its fence with leaseFence()", () => { + const offenders: string[] = []; + let audited = 0; + + for (const rel of LEASE_WRITERS) { + const src = source(rel); + for (const call of mutatingCalls(src)) { + // Only the calls that fence on a lease at all. A write with no + // `uploadLeaseVersion` in its arguments is making no claim about + // which lease it observed, so it is out of scope here. + if (!call.args.includes("uploadLeaseVersion")) continue; + audited++; + if (call.args.includes("leaseFence(")) continue; + const line = src.slice(0, call.at).split("\n").length; + offenders.push(`${rel}:${line} .${call.name}()`); + } + } + + assert.deepEqual( + offenders, + [], + `these lease-bearing writes do not go through leaseFence():\n ${offenders.join("\n ")}`, + ); + // The tripwire has to be LOOKING at something. A rename of the column, or a + // file list gone stale, would otherwise leave it green while auditing + // nothing at all. + assert.ok(audited >= 1, `expected at least one lease-fenced write, audited ${audited}`); +}); + +test("the tripwire FAILS on a half fence — the pre-fix control", () => { + // The exact shape all of the last rounds' findings had: a where pinning the + // state and the version and nothing else. Run through the same extractor, + // so this proves the check would have caught them. + const halfFenced = [ + "await prisma.receiptIntake.updateMany({", + ' where: { id: row.id, state: "STAGING", uploadLeaseVersion: row.uploadLeaseVersion },', + ' data: { state: "NEEDS_REVIEW" },', + "});", + ].join("\n"); + const [call] = mutatingCalls(halfFenced); + assert.ok(call.args.includes("uploadLeaseVersion"), "it is in scope"); + assert.ok(!call.args.includes("leaseFence("), "and it would be reported"); + + // ...and it is accepted once the fence goes through the builder. + const [fixed] = mutatingCalls([ + "await prisma.receiptIntake.updateMany({", + " where: { id: row.id, ...leaseFence(row) },", + ' data: { state: "NEEDS_REVIEW" },', + "});", + ].join("\n")); + assert.ok(fixed.args.includes("leaseFence(")); +}); + +test("publishFence has NO caller left: every lease writer takes the full fence", () => { + // It used to have exactly one exception -- reuseLiveLease -- on the + // reasoning that pinning the nonce there would turn an honest second retry + // into a 409. That reasoning was the round-19 bug: leaving the nonce out + // let BOTH retries write, each stamping its own generation, so the earlier + // caller's 200 carried a lease /finalize refuses. The rule now pins the + // whole fence and CONVERGES the loser on the winner's lease instead, so + // the exception is gone and the weaker builder has no user outside the + // module that defines it. + const users = LEASE_WRITERS.filter(rel => /\bpublishFence\(/.test(source(rel))); + assert.deepEqual( + users, + ["src/lib/receipt-intake/stored-object.ts"], + "publishFence is referenced only where it is defined", + ); + const lease = source("src/lib/receipt-intake/upload-lease.ts"); + const body = functionBody(lease, "export async function reuseLiveLease"); + assert.match(body, /\.\.\.leaseFence\(observed\)/, "the adoption CAS carries the generation"); + assert.ok(!/publishFence\(/.test(body), "and nothing weaker"); +}); + +test("leaseFence is a SUPERSET of publishFence, and names the lease generation", () => { + // If the builder ever stopped carrying the nonce or the expiry, every call + // site would keep compiling and every fence would silently weaken. + const stored = source("src/lib/receipt-intake/stored-object.ts"); + const body = functionBody(stored, "export function leaseFence"); + assert.match(body, /\.\.\.publishFence\(row\)/); + assert.match(body, /uploadLeaseNonce: row\.uploadLeaseNonce/); + assert.match(body, /uploadUrlExpiresAt: row\.uploadUrlExpiresAt/); +}); + +// ── /finalize IS BOUND TO THE LEASE THAT ISSUED ITS URL (round-15 item 1) ── +// +// /start rotates `uploadLeaseNonce` on every issue and every adoption, and it +// now RETURNS that value. /finalize used to read the row's CURRENT nonce, so a +// delayed finalizer silently ADOPTED whichever lease had been issued since — +// and both /start calls hand out URLs for the SAME path. Client A starts an +// upload; B's retry refreshes the lease and gets a working URL; A's finalize +// finally arrives, inspects B's half-written object, judges it unacceptable +// and DELETES the row while B is still uploading to a URL that works. + +test("every /start response echoes the generation its URL was issued under", () => { + // Five ways out of /start, five leases. A branch that forgot to echo one + // would hand a client a URL it could never finalize. + const start = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/start/route.ts"), + "utf8", + ); + // Each of the three appears TWICE now: once in the re-read that confirms + // it is still the persisted generation, and once in the response. + for (const name of ['leaseNonce', 'rearmedLease', 'resumedLease'] as const) { + const uses = (start.match(new RegExp(`uploadLease: ${name}\\b`, 'g')) ?? []).length; + assert.equal(uses, 2, `${name} is confirmed and then echoed`); + } + // The other two come through the shared reuse rule, which returns it. + const lease = readFileSync(path.join(ROOT, "src/lib/receipt-intake/upload-lease.ts"), "utf8"); + // AN EXTENSION KEEPS THE GENERATION IT ADOPTED. Minting a fresh one per + // adoption is what stranded the first of two concurrent 200s: only the + // last write survives, and /finalize refuses every earlier nonce. + assert.match(lease, /const uploadLease = observed\.uploadLeaseNonce \?\? \(deps\.nonce \?\? newLeaseNonce\)\(\);/); + assert.match(lease, /signed: \{ \.\.\.signed, uploadLease \}/); + // ...and it is the SAME value written to the row, not a second draw. + assert.match(lease, /uploadLeaseNonce: uploadLease,/); + + // AND NO BRANCH RETURNS A LEASE IT HAS NOT RE-READ. The three that mint + // a genuinely new one write, then sign, then answer -- and a concurrent + // /start can move the row inside that gap. + const confirms = (start.match(/await issuedLeaseIsCurrent\(/g) ?? []).length; + assert.equal(confirms, 3, "create, re-arm and resume each re-read before answering"); + // The reuse rule confirms its own, by looping rather than by conflicting. + assert.match(lease, /const confirmed = await deps\.reload\(observed\.id\);/); +}); + +test("/finalize REQUIRES the lease, and refuses a stale one before touching storage", () => { + const finalize = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + // The gate exists, and says which lease it compares against. + assert.match(finalize, /if \(!declaredLease \|\| declaredLease !== row\.uploadLeaseNonce\)/); + assert.match(finalize, /error: "lease-stale"/); + assert.match(finalize, /retryable: false/, "a stale lease is not fixed by retrying"); + + // ORDER IS THE PROPERTY. After authorization (so a caller who may not see + // the row cannot learn its lease is stale), and before the declared-hash + // check, the disposition split, and every storage call. + const gateAt = finalize.indexOf('error: "lease-stale"'); + const authAt = finalize.indexOf("const maySee"); + const shaAt = finalize.indexOf("declaredShaConflict(row.fileSha256"); + const inspectAt = finalize.indexOf("await inspectStoredObject("); + const rejectAt = finalize.indexOf("rejectRowAndQueueCleanup("); + const publishAt = finalize.indexOf("await sealAndPublish("); + assert.ok(authAt > 0 && authAt < gateAt, "authorization first"); + for (const [name, at] of [["sha", shaAt], ["inspect", inspectAt], ["reject", rejectAt], ["publish", publishAt]] as const) { + assert.ok(at > gateAt, `the gate runs before ${name}`); + } +}); + +test("the fences pin the ECHOED lease, not the freshly read one", () => { + // Equal by the gate — and written this way so a future edit that moved or + // weakened the gate leaves these CASes pinning a generation nobody proved. + const finalize = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + assert.match(finalize, /const leased = \{ \.\.\.row, uploadLeaseNonce: declaredLease \};/); + assert.match(finalize, /\.\.\.leaseFence\(leased\)/); + assert.match(finalize, /uploadLeaseNonce: leased\.uploadLeaseNonce,/, "the reject fence too"); + // PRE-FIX CONTROL: no fence still reads the row's own nonce directly. + assert.ok( + !/\.\.\.leaseFence\(row\)/.test(finalize), + "nothing fences on the freshly-read row any more", + ); +}); + +test("LEASE-STALE: the ordering, as a decision table", () => { + // The gate is a pure comparison, so its truth table is a unit test rather + // than a race. `stale` is what the route computes. + const stale = (declared: string | null, current: string | null) => + !declared || declared !== current; + + assert.equal(stale("nonce-a", "nonce-a"), false, "the lease it was issued under"); + assert.equal(stale("nonce-a", "nonce-b"), true, "a lease that has since been refreshed"); + assert.equal(stale(null, "nonce-a"), true, "omitting it is not a way round the gate"); + assert.equal(stale("nonce-a", null), true, "a row that never had a signed URL"); + assert.equal(stale(null, null), true, "and neither is the null/null case"); +}); + +// ── NO EXTERNAL I/O INSIDE A DATABASE TRANSACTION (round-17 item 3) ─────── +// +// The advisory-lock scheme held an interactive transaction — a pooled +// connection — across Supabase calls the round-16 deadline caps at fifteen +// seconds. Four concurrent finalizations exhausted the five-connection pool. +// The lock is gone; this is the tripwire that stops it coming back under +// another name. + +test("the object-lock helpers are GONE, and nothing reaches for them", () => { + for (const rel of LEASE_WRITERS) { + const src = source(rel); + for (const banned of ["withReceiptObjectLock(", "withReceiptPublishLock("]) { + assert.ok( + !src.includes(banned), + `${rel} still calls ${banned}: external I/O must not run inside a transaction`, + ); + } + } +}); + +test("every advisory lock is PURE database work, and short", () => { + // `pg_advisory_xact_lock` is not banned -- it is the right tool for a + // critical section that is purely database work. The locks this feature + // once had were different in kind: they wrapped Supabase round trips, so a + // storage stall held a pooled connection for its whole duration. + // + // TWO sections take one now, and neither reaches outside Postgres: + // - promoteToBooking's weak-key check, which serializes two rows + // sharing a dedup key across a SELECT and an UPDATE; + // - acquireObjectClaim, which serializes the publisher's and the + // sweeper's claim transactions over one object path. Those two used to + // touch DIFFERENT rows, so at READ COMMITTED both could read the path + // as free and both commit -- and the sweeper then deleted an object a + // publisher had just sealed. + const users = LEASE_WRITERS.filter(rel => source(rel).includes("pg_advisory_xact_lock")); + assert.deepEqual( + [...users].sort(), + [ + "src/app/api/cron/receipt-intake-worker/route.ts", + "src/lib/receipt-intake/storage-cleanup.ts", + ], + "the weak-key promotion and the object claim, and nothing else", + ); + + const cron = source("src/app/api/cron/receipt-intake-worker/route.ts"); + const promote = cron.slice(cron.indexOf("promoteToBooking: async")); + const weakKey = promote.slice(0, promote.indexOf("book: row =>")); + assert.match(weakKey, /pg_advisory_xact_lock/, "it lives where the comment says"); + + const cleanup = source("src/lib/receipt-intake/storage-cleanup.ts"); + const lockBody = functionBody(cleanup, "export async function lockObjectPath"); + assert.match(lockBody, /pg_advisory_xact_lock\(hashtext\(/, "keyed by the path"); + + // THE CLAIM TRANSACTIONS THEMSELVES touch nothing outside the database. + const acquireBody = functionBody(cleanup, "export async function acquireObjectClaim"); + for (const body of [weakKey, lockBody, acquireBody]) { + // `.storage.` rather than a bare "storage": `storagePath` is a parameter + // name in every one of these sections, and a substring check that trips + // on it is a check that can only be satisfied by renaming variables. + for (const external of [".storage.", "downloadReceiptObject", "removeReceiptObject", "uploadReceiptObject", "fetch("]) { + assert.ok(!body.includes(external), `an advisory-lock section calls nothing external (${external})`); + } + } + + // AND IT IS THE FIRST STATEMENT of both claim transactions. A read taken + // before the lock is a read the other claimant can invalidate. + assert.match(acquireBody, /^\s*await lockObjectPath\(tx, storagePath\);/m); + const publisherBody = functionBody(cleanup, "export async function claimObjectPath"); + assert.ok( + publisherBody.indexOf("acquireObjectClaim(") < publisherBody.indexOf("reclaimQueuedCleanups("), + "the publisher claims before it touches anything else", + ); +}); + +test("every transaction helper the intake uses is a SHORT one", () => { + // `inShortTx` is the only transaction wrapper in this feature, and its + // timeout says so: a body that needs longer than five seconds without + // external I/O is doing something its own doc comment forbids. + const cleanup = source("src/lib/receipt-intake/storage-cleanup.ts"); + const body = functionBody(cleanup, "export async function inShortTx"); + assert.match(body, /prisma\.\$transaction\(body, \{ maxWait: 5_000, timeout: 5_000 \}\)/); + // The 30-second window the lock needed is gone with it. + assert.ok(!cleanup.includes("timeout: 30_000"), "no transaction is sized for a storage round trip"); +}); + +test("the SEAL happens outside every transaction — asserted on the shipped order", () => { + // The publish protocol, read off the source: claim, then seal, THEN open a + // transaction. A future edit that moved the seal back inside would have to + // move it past the `inShortTx(` call to pass this. + const stored = source("src/lib/receipt-intake/stored-object.ts"); + const fn = stored.slice(stored.indexOf("export async function sealAndPublish")); + const claimAt = fn.indexOf("deps.claimCanonicalPath("); + const sealAt = fn.indexOf("await deps.seal("); + const txAt = fn.indexOf("await deps.inShortTx("); + assert.ok(claimAt > 0 && sealAt > 0 && txAt > 0, "all three phases are present"); + assert.ok(claimAt < sealAt, "the path is claimed before it is written"); + assert.ok(sealAt < txAt, "and the seal precedes any open transaction"); +}); + +test("the cleanup sweep does its DELETE outside a transaction too", () => { + // Same shape on the other side: claim in a short tx, delete with none + // open, settle in a second short tx. + const cleanup = source("src/lib/receipt-intake/storage-cleanup.ts"); + const fn = cleanup.slice(cleanup.indexOf("export async function retryPendingCleanups")); + const claimAt = fn.indexOf("const claim = await deps.inShortTx("); + const removeAt = fn.indexOf("await deps.remove(storagePath)"); + const settleAt = fn.indexOf("const settled = await deps.inShortTx("); + assert.ok(claimAt > 0 && removeAt > 0 && settleAt > 0); + assert.ok(claimAt < removeAt, "the claim comes first"); + assert.ok(removeAt < settleAt, "the delete runs between the two transactions, not inside either"); +}); + +// ── Why the DB-gated proof may not use the app's prisma singleton ────────── +// +// CI's "Migrations reproduce production" job failed on the connection-hold +// tests with `not ok 11` and `not ok 13`. The cause was not the concurrency +// protocol they measure. Those tests called the shipped `inShortTx`, which +// runs on the app's prisma singleton — and that singleton REFUSES a +// DATABASE_URL without `pgbouncer=true`. The rule is correct (Supabase's +// transaction pooler needs it, and without it prod falls over with 42P05), and +// CI's migrations job points at a plain Postgres container, so the singleton +// could never be built there. `sealAndPublish` caught the throw, returned its +// retryable null, and the assertion saw `undefined` — a connection-string +// failure wearing a concurrency failure's clothes. +// +// The tests now build their transaction helper over their own client. These +// two guards keep that true and record why. + +test("the app's prisma singleton REFUSES a plain Postgres URL — the root cause", async () => { + const before = process.env.DATABASE_URL; + // CI's migrations job, verbatim from .github/workflows/ci.yml. + process.env.DATABASE_URL = "postgresql://probuild:probuild@localhost:5432/probuild_migrations"; + try { + const { prisma } = await import("../src/lib/prisma"); + assert.throws( + () => { void (prisma as unknown as Record).receiptIntake; }, + /pgbouncer=true/, + "any test touching the singleton dies on CI's plain Postgres, whatever it meant to measure", + ); + } finally { + if (before === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = before; + } +}); + +test("the migrations job's URL really is the plain one, and e2e's is not", () => { + const ci = readFileSync(path.join(ROOT, ".github/workflows/ci.yml"), "utf8"); + const urls = ci.match(/postgresql:\/\/probuild:probuild@localhost:5432\/\S+/g) ?? []; + assert.ok(urls.length >= 2, "both jobs name their database"); + assert.ok( + urls.some(u => u.includes("probuild_migrations") && !u.includes("pgbouncer")), + "the migrations job is plain Postgres — the singleton cannot be built there", + ); + assert.ok( + urls.some(u => u.includes("probuild_e2e") && u.includes("pgbouncer=true")), + "the e2e job carries the flag, which is why it never hit this", + ); +}); + +test("the DB-gated proof builds its OWN client, never the singleton", () => { + const db = readFileSync(path.join(ROOT, "tests/receipt-intake-claim-db.test.ts"), "utf8"); + assert.ok( + !/inShortTx.*from "\.\.\/src\/lib\/receipt-intake\/storage-cleanup"/.test(db), + "it must not import the singleton-backed transaction helper", + ); + assert.ok(!/from "\.\.\/src\/lib\/prisma"/.test(db), "nor the singleton itself"); + assert.match(db, /const shortTx = /, "it builds the short transaction over its own client"); + assert.match(db, /db!\.\$transaction\(tx => body\(tx\), \{ maxWait: 5_000, timeout: 5_000 \}\)/, + "with the SAME options as the shipped helper, so the protocol is what is measured"); +}); + +// -- A MESSAGE PASSED TO A NO-ARGUMENT MATCHER IS NOT AN ASSERTION --------- +// +// `expect(x).toBeUndefined("why")` does not check anything: Playwright +// refuses it with `Matcher error: this matcher must not have an expected +// argument`, so the test fails on the CALL rather than on the value -- and +// while it is failing it is telling you nothing about the value at all. It +// cost a red CI run on the round-19 /start union specs, where three +// assertions about the response shape had never once been evaluated. The +// message belongs on expect(): `expect(x, "why").toBeUndefined()`. +// +// tsc does not catch it (Playwright types these matchers as `(...args: any)`), +// so a source check is the only thing that can. + +test("no e2e spec passes a message to a matcher that takes no argument", () => { + const dir = path.join(ROOT, "e2e"); + const specs = readdirSync(dir).filter(name => name.endsWith(".spec.ts")); + assert.ok(specs.length > 10, "the spec folder was found"); + + const noArgMatchers = /\.(toBeTruthy|toBeFalsy|toBeUndefined|toBeDefined|toBeNull|toBeNaN)\(\s*[^)\s]/g; + const offenders: string[] = []; + for (const name of specs) { + const body = readFileSync(path.join(dir, name), "utf8"); + body.split(/\r?\n/).forEach((line, i) => { + noArgMatchers.lastIndex = 0; + if (noArgMatchers.test(line)) offenders.push(`${name}:${i + 1} ${line.trim()}`); + }); + } + assert.deepEqual(offenders, [], `pass the message to expect() instead:\n${offenders.join("\n")}`); +}); +test("the object lock is keyed by the PATH, not by a constant", () => { + // A constant key would still serialize -- and would serialize EVERYTHING, + // turning a per-object mutex into a global one that two unrelated receipts + // queue behind. The granularity is the point, and it is not observable from + // a race between two claimants of the SAME path. + const cleanup = source("src/lib/receipt-intake/storage-cleanup.ts"); + const lockBody = functionBody(cleanup, "export async function lockObjectPath"); + assert.match( + lockBody, + /hashtext\(\$\{OBJECT_LOCK_PREFIX \+ storagePath\}\)/, + "the key carries the path", + ); + // And the prefix is a namespace, not the whole key: two features hashing + // bare paths into one advisory-lock space would collide on nothing useful. + assert.match(cleanup, /export const OBJECT_LOCK_PREFIX = "receipt-object:";/); +}); diff --git a/tests/receipt-intake-phases.test.ts b/tests/receipt-intake-phases.test.ts new file mode 100644 index 000000000..f4963276a --- /dev/null +++ b/tests/receipt-intake-phases.test.ts @@ -0,0 +1,68 @@ +/** + * loadPhases must offer the model ONLY the phases the job actually has. + * + * The regression: it returned every active cost code company-wide, so the model + * was shown phases the project does not have, confidently suggested one, and + * booking then threw that suggestion away (isCostCodeAllowedForProject). The + * visible symptom was receipts arriving uncoded for no stated reason. The real + * cost is subtler: a plausible-but-wrong phase is exactly the kind of thing a + * reviewer accepts without checking. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveProjectPhaseCodes, type PhaseDataSource } from "../src/lib/project-phases"; + +function source(over: Partial = {}): PhaseDataSource { + return { + getProject: async () => ({ id: "p1", status: "In Progress" }), + getEstimateCostCodes: async () => [ + { id: "cc-demo", code: "01-DEMO", name: "Demolition", isActive: true }, + ], + getSafetyCostCode: async () => null, + ...over, + } as PhaseDataSource; +} + +/** Mirrors the worker's adapter exactly. */ +async function loadPhases(projectId: string | null, ds: PhaseDataSource) { + if (!projectId) return []; + const phases = await resolveProjectPhaseCodes(ds, projectId); + return phases.map(p => ({ id: p.id, code: p.code, name: p.name })); +} + +test("a known project returns only ITS phase-eligible codes", async () => { + const phases = await loadPhases("p1", source()); + assert.deepEqual(phases.map(p => p.code), ["01-DEMO"]); +}); + +test("a project with NO eligible phases returns an empty list, not a fallback", async () => { + // This is the case the old code papered over. An empty list is a real + // answer: nothing on this job is a valid phase, so the model must suggest + // nothing rather than reach for a company-wide code that booking will + // discard. + const phases = await loadPhases("p1", source({ getEstimateCostCodes: async () => [] })); + assert.deepEqual(phases, []); +}); + +test("an unknown project returns empty rather than everything", async () => { + const phases = await loadPhases("nope", source({ getProject: async () => null })); + assert.deepEqual(phases, []); +}); + +test("a row with no project gets no phases at all", async () => { + // Suggesting one from the whole company would be a guess with nothing + // behind it — and NEEDS_JOB rows are exactly the ones a human is about to + // assign, so a stale suggestion is worse than none. + let called = false; + await loadPhases(null, source({ getProject: async () => { called = true; return null; } })); + assert.equal(called, false, "the resolver is not even consulted"); +}); + +test("the Safety phase is included when the project status allows it", async () => { + // Proof this really is the shared resolver and not a reimplementation: + // Safety is a phase no estimate lists, and only the resolver knows to add it. + const phases = await loadPhases("p1", source({ + getSafetyCostCode: async () => ({ id: "cc-safety", code: "00-SAFETY", name: "Safety Meeting", isActive: true }), + })); + assert.ok(phases.some(p => p.code === "00-SAFETY")); +}); diff --git a/tests/receipt-intake-raw-sql.test.ts b/tests/receipt-intake-raw-sql.test.ts new file mode 100644 index 000000000..3156f7d03 --- /dev/null +++ b/tests/receipt-intake-raw-sql.test.ts @@ -0,0 +1,120 @@ +/** + * Raw-SQL tripwires and the real transaction paths. + * + * The pure-logic suites mock every database call, so the SQL itself is the one + * part of this feature they cannot see. Two failures live there and both are + * silent until production: selecting a void-returning function, and a claim + * transaction that does not behave the way the mocked version implies. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +const ROOT = path.resolve(__dirname, ".."); + +/** Line and block comments only — enough to stop prose ABOUT the rule tripping it. */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, " ") + .split("\n") + .map(line => line.replace(/\/\/.*$/, "")) + .join("\n"); +} + +/** The raw-SQL helper nearest BEFORE `at` — the one that issues that statement. */ +function nearestRawHelper(source: string, at: number): string | null { + const helpers = ["$queryRawUnsafe", "$queryRaw", "$executeRawUnsafe", "$executeRaw"]; + let best: { name: string; index: number } | null = null; + for (const name of helpers) { + const index = source.lastIndexOf(name, at); + if (index === -1) continue; + // Prefer the LONGEST match at the same position, so "$queryRawUnsafe" + // is not read as "$queryRaw" plus stray characters. + if (!best || index > best.index || (index === best.index && name.length > best.name.length)) { + best = { name, index }; + } + } + return best?.name ?? null; +} + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === ".next" || entry.startsWith(".")) continue; + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.tsx?$/.test(entry)) out.push(full); + } + return out; +} + +/** + * Functions that return `void`. `SELECT`ing one through $queryRaw produces a + * row whose single column has no readable type, which Prisma's query path can + * reject outright — and inside a transaction that throw looks like a transient + * DB fault forever while the lock was never actually taken. $executeRaw runs + * the statement for its effect and asks nothing of the result. + */ +const VOID_FUNCTIONS = [ + "pg_advisory_xact_lock", + "pg_advisory_lock", + "pg_advisory_unlock_all", +]; + +test("no $queryRaw anywhere SELECTs a void-returning function unreadably", () => { + // The rule: find the raw-SQL helper that ISSUES this call — the nearest one + // before it — and require that it is not a result-reading form, unless the + // call is cast to a readable type. + // + // Two shapes are correct and must not be flagged, or the tripwire gets + // muted as noise: + // * $executeRaw / $executeRawUnsafe — runs the statement for its effect. + // * an explicit cast, e.g. `pg_advisory_xact_lock(...)::text AS x`, which + // is exactly how qbo-expense-sync.ts makes its lock readable. + // Comments are stripped first, because selection-ai-sort-apply-core.ts + // explains this very rule in prose directly above a CORRECT call. + const offenders: string[] = []; + for (const file of walk(path.join(ROOT, "src"))) { + const source = stripComments(readFileSync(file, "utf8")); + for (const fn of VOID_FUNCTIONS) { + let at = source.indexOf(fn + "("); + while (at !== -1) { + const issuer = nearestRawHelper(source, at); + const cast = /\)\s*::\s*\w+/.test(source.slice(at, at + 200)); + if (issuer && issuer.startsWith("$queryRaw") && !cast) { + offenders.push(path.relative(ROOT, file) + " -> " + fn); + break; + } + at = source.indexOf(fn + "(", at + 1); + } + } + } + assert.deepEqual(offenders, [], "use $executeRaw, or cast the result to a readable type"); +}); + +test("the tripwire actually catches the shape it exists for", () => { + // Without this the test above passes just as happily on an empty scan. + const bad = 'await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(k, 0))`;'; + const at = bad.indexOf("pg_advisory_xact_lock("); + assert.ok(bad.slice(0, at).includes("$queryRaw"), "the offending shape is recognisable"); + assert.ok(!/\)\s*::\s*\w+/.test(bad.slice(at)), "and it has no rescuing cast"); +}); + +test("the TRY variant returns a boolean, so it is correctly read with $queryRaw", () => { + // pg_try_advisory_xact_lock returns bool — reading it is the whole point, + // and this pins that the two are not confused for each other. + const worker = readFileSync( + path.join(ROOT, "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", + ); + const tryAt = worker.indexOf("pg_try_advisory_xact_lock("); + assert.ok(tryAt > 0, "the try-lock is present"); + assert.ok(worker.slice(tryAt - 200, tryAt).includes("$queryRaw"), "try-lock is READ with $queryRaw"); + + const blockingAt = worker.indexOf("pg_advisory_xact_lock(hashtextextended"); + assert.ok(blockingAt > 0, "the blocking lock is present"); + assert.ok( + worker.slice(blockingAt - 300, blockingAt).includes("$executeRaw"), + "the blocking (void) lock is EXECUTED, never selected", + ); +}); diff --git a/tests/receipt-intake-read.test.ts b/tests/receipt-intake-read.test.ts new file mode 100644 index 000000000..a0566d60b --- /dev/null +++ b/tests/receipt-intake-read.test.ts @@ -0,0 +1,296 @@ +/** + * The reader, driven through an INJECTED fetch — no network, no module mocks + * (CI is Node 20, where `mock.module` corrupts the require chain). + * + * Two things are pinned here: + * 1. the load-bearing sentences of the v3.6 prompt. Each one was added after a + * specific misread (subtotal booked instead of the total; an invented tax + * line; a scanned stack of receipts booked as one purchase), so a tidy-up + * edit that drops one is a money bug, not a style change. + * 2. the outage discipline: "the service was busy" and "this document defeated + * the AI" must stay DIFFERENT answers. Collapsing them parked five legible + * receipts during the 2026-08-10..19 outage. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildReadPrompt, normalizeConfidence, parseReadJson, readReceipt } from "../src/lib/receipt-intake/read"; + +const PHASES = [ + { code: "01-DEMO", name: "Demolition" }, + { code: "03-PLUMB", name: "Plumbing" }, +]; + +const BYTES = Buffer.from("fake-jpeg-bytes"); + +function geminiJson(payload: unknown): Response { + return new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: JSON.stringify(payload) }] } }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +const noSleep = async () => {}; + +test("the prompt carries the v3.6 rules that decide money", () => { + const prompt = buildReadPrompt(PHASES); + + // The final-amount rule: the number that matches the bank charge. + assert.ok(prompt.includes( + "total_amount is the FINAL amount paid — after all discounts, coupons, and credits, and " + + "including tax and fees." + ), "final-amount rule"); + assert.ok(prompt.includes("NEVER the subtotal, and never the pre-discount price."), "subtotal rule"); + + // The never-estimate-tax rule: an ABSENT tax is not a ZERO tax, and a + // computed one would corrupt the reseller-permit filing. + assert.ok(prompt.includes( + 'return "" if no tax line is shown or it cannot be read confidently — never estimate or ' + + "compute it yourself." + ), "never-estimate-tax rule"); + + // The multi rule: a stack of receipts scanned into one PDF is not one purchase. + assert.ok(prompt.includes( + 'STEP 1 - if the file contains MORE THAN ONE separate receipt, invoice, or check' + ), "multi rule"); + assert.ok(prompt.includes('return exactly {"doc_type":"multi"} and nothing else.'), "multi output"); + assert.ok(prompt.includes('return exactly {"doc_type":"non_receipt"} and nothing else.'), "non_receipt output"); + + // Unreadable fields come back empty rather than guessed. + assert.ok(prompt.includes('If a field cannot be read, return "" for it. For the date, return "" rather than guessing.')); +}); + +test("the appended phase section lists the job's codes and nothing else", () => { + const prompt = buildReadPrompt(PHASES); + assert.ok(prompt.includes("01-DEMO — Demolition")); + assert.ok(prompt.includes("03-PLUMB — Plumbing")); + assert.ok(prompt.includes('"suggested_phase"')); + // The v1 extraction half stays BYTE-IDENTICAL: the phase section can only + // ever be appended, never woven into the rules above it. + const v1Only = buildReadPrompt([]); + assert.ok(prompt.startsWith(v1Only), "the phase section is strictly appended"); + assert.ok(!v1Only.includes("suggested_phase"), "a job with no cost codes gets the v1 prompt"); +}); + +test("a well-formed response parses into ReadResult", async () => { + let capturedBody: string | undefined; + const outcome = await readReceipt(BYTES, "image/jpeg", PHASES, { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async (_url: string, init: RequestInit) => { + capturedBody = init.body as string; + return geminiJson({ + doc_type: "receipt", + vendor: "Lowes", + date: "2026-08-03", + invoice: "82766", + check_number: "", + memo: "", + total_amount: "364.98", + tax_amount: "29.20", + suggested_phase: "03-PLUMB", + }); + }) as unknown as typeof fetch, + }); + + assert.ok(outcome.ok); + assert.equal(outcome.read.vendor, "Lowes"); + assert.equal(outcome.read.date, "2026-08-03"); + assert.equal(outcome.read.totalAmount, "364.98"); + assert.equal(outcome.read.taxAmount, "29.20"); + assert.equal(outcome.read.suggestedPhaseCode, "03-PLUMB"); + assert.ok(outcome.read.raw.includes("364.98"), "raw JSON is kept for audit"); + + const sent = JSON.parse(capturedBody!); + assert.equal(sent.generationConfig.responseMimeType, "application/json"); + assert.equal(sent.contents[0].parts[1].inline_data.mime_type, "image/jpeg"); +}); + +test("text/plain goes in as a text part, not inline_data", async () => { + let capturedBody: string | undefined; + await readReceipt(Buffer.from("VENDOR: Lowes\nTOTAL: 10.00"), "text/plain; charset=utf-8", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async (_url: string, init: RequestInit) => { + capturedBody = init.body as string; + return geminiJson({ doc_type: "receipt", total_amount: "10.00" }); + }) as unknown as typeof fetch, + }); + const sent = JSON.parse(capturedBody!); + assert.ok(sent.contents[0].parts[1].text.startsWith("This is a text file containing receipt data:")); +}); + +test("an off-list phase suggestion is discarded, not trusted", () => { + const parsed = parseReadJson(JSON.stringify({ doc_type: "receipt", suggested_phase: "99-INVENTED" }), PHASES); + assert.equal(parsed?.suggestedPhaseCode, ""); +}); + +test("503 retries twice on 1s/3s, then falls through to the next model", async () => { + // The Apps Script could afford 5 retries at 2s..32s; this worker has 60s for + // a batch of ten, so one busy document must not eat the invocation. + const calls: string[] = []; + const sleeps: number[] = []; + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: async (ms) => { sleeps.push(ms); }, + fetchFn: (async (url: string) => { + calls.push(url); + if (calls.length <= 3) return new Response("busy", { status: 503 }); + return geminiJson({ doc_type: "receipt", total_amount: "1.00" }); + }) as unknown as typeof fetch, + }); + assert.ok(outcome.ok, "the second model answered"); + assert.equal(calls.length, 4, "3 attempts on model 1, then model 2"); + assert.ok(calls[0].includes("gemini-3.5-flash")); + assert.ok(calls[3].includes("gemini-flash-latest"), "fell through to the next model"); + assert.deepEqual(sleeps, [1000, 3000], "2 retries per model"); +}); + +test("the 25s budget is a hard ceiling across models and backoffs", async () => { + // A row that cannot be read inside its budget comes back next pass at no + // cost to itself. What it must NOT do is keep the worker's 60s function + // open while nine other receipts wait behind it. + let clock = 0; + const calls: string[] = []; + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + monotonicMs: () => clock, + sleep: async (ms) => { clock += ms; }, + fetchFn: (async (url: string) => { + calls.push(url); + clock += 9_000; // each call burns 9s + return new Response("busy", { status: 503 }); + }) as unknown as typeof fetch, + }); + // AI_UNAVAILABLE, never decisive: the document was never read, so the + // caller must not spend one of its attempts. + assert.deepEqual(outcome, { ok: false, decisive: false }); + assert.ok(clock <= 25_000 + 9_000, `budget overrun: ${clock}ms`); + assert.ok(calls.length <= 3, `budget should have stopped the retries, got ${calls.length} calls`); +}); + +test("a per-request timeout never outlives the remaining budget", async () => { + let clock = 0; + const timeouts: number[] = []; + await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + monotonicMs: () => clock, + sleep: async (ms) => { clock += ms; }, + fetchFn: (async (_url: string, init: RequestInit) => { + // AbortSignal.timeout is opaque; assert on the budget arithmetic by + // advancing the clock and checking the signal was created at all. + assert.ok(init.signal, "every request carries an abort signal"); + timeouts.push(clock); + clock += 5_000; + return new Response("busy", { status: 503 }); + }) as unknown as typeof fetch, + }); + // First call at 0ms, then 1s backoff -> 6s, then 3s backoff -> 14s... + assert.equal(timeouts[0], 0); + assert.ok(timeouts.every(t => t < 25_000), "no request starts after the budget is gone"); +}); + +test("every model unavailable is NOT decisive — the row must not spend an attempt", async () => { + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch, + }); + assert.deepEqual(outcome, { ok: false, decisive: false }); +}); + +test("a model that answers with unusable JSON IS decisive", async () => { + // The model responded; retrying will not make this document readable. + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async () => new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: "not json at all" }] } }] }), + { status: 200 }, + )) as unknown as typeof fetch, + }); + assert.deepEqual(outcome, { ok: false, decisive: true }); +}); + +test("HTTP 400 (payload rejected) is decisive and stops immediately", async () => { + let calls = 0; + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async () => { calls++; return new Response("too big", { status: 400 }); }) as unknown as typeof fetch, + }); + assert.deepEqual(outcome, { ok: false, decisive: true }); + assert.equal(calls, 1, "a rejected payload is not retried against a second model"); +}); + +test("a missing API key is a SERVICE fact, never charged to the document", async () => { + let calls = 0; + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => undefined, + sleep: noSleep, + fetchFn: (async () => { calls++; return geminiJson({}); }) as unknown as typeof fetch, + }); + assert.deepEqual(outcome, { ok: false, decisive: false }); + assert.equal(calls, 0); +}); + +test("EVERY 5xx is the service failing, never the document (busy pass, not a strike)", async () => { + // 500/502/504 used to fall into the "decisive" branch and charge the row a + // strike for a Google-side fault it had nothing to do with — exactly what + // the outage rationale exists to prevent. A gateway error says nothing + // about whether the receipt is readable. + for (const status of [500, 502, 503, 504, 529]) { + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async () => new Response("server fault", { status })) as unknown as typeof fetch, + }); + assert.deepEqual(outcome, { ok: false, decisive: false }, `HTTP ${status}`); + } +}); + +test("a 5xx on the first model still falls through to the second", async () => { + const calls: string[] = []; + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async (url: string) => { + calls.push(url); + if (calls.length <= 3) return new Response("bad gateway", { status: 502 }); + return geminiJson({ doc_type: "receipt", total_amount: "1.00" }); + }) as unknown as typeof fetch, + }); + assert.ok(outcome.ok, "the second model answered"); + assert.ok(calls[3].includes("gemini-flash-latest")); +}); + +test("a 4xx that is not 401/403/404/429 is still DECISIVE", async () => { + // A rejected payload is about this document, and retrying cannot help. + for (const status of [400, 413, 422]) { + const outcome = await readReceipt(BYTES, "image/jpeg", [], { + apiKey: () => "test-key", + sleep: noSleep, + fetchFn: (async () => new Response("rejected", { status })) as unknown as typeof fetch, + }); + assert.deepEqual(outcome, { ok: false, decisive: true }, `HTTP ${status}`); + } +}); + +test("an absent confidence is NULL, never 0", () => { + // `Number("")` and `Number(" ")` are both 0 — a real, maximally- + // unconfident reading. Coercing first turned "the model said nothing" into + // "the model is certain this phase is a poor match", and the queue sorts on + // exactly that number. + for (const empty of ["", " ", "\t", undefined, null, {}, [], "abc", NaN, Infinity]) { + assert.equal(normalizeConfidence(empty), null, JSON.stringify(empty)); + } + // A genuine zero survives as a zero. + assert.equal(normalizeConfidence(0), 0); + assert.equal(normalizeConfidence("0"), 0); + assert.equal(normalizeConfidence("0.0"), 0); + // Normal values, and clamping at the edges. + assert.equal(normalizeConfidence(0.82), 0.82); + assert.equal(normalizeConfidence("0.82"), 0.82); + assert.equal(normalizeConfidence(1.2), 1); + assert.equal(normalizeConfidence(-3), 0); + assert.equal(normalizeConfidence(" 0.5 "), 0.5, "whitespace around a real number is fine"); +}); diff --git a/tests/receipt-intake-reject.test.ts b/tests/receipt-intake-reject.test.ts new file mode 100644 index 000000000..efdaaf439 --- /dev/null +++ b/tests/receipt-intake-reject.test.ts @@ -0,0 +1,702 @@ +/** + * Rejecting a row, and publishing one. + * + * Both are two writes that must agree. A reject deletes the row AND queues its + * object for deletion: do them separately and either the bytes are orphaned + * with nothing left to remember them (delete first, record fails) or the queue + * names a path a live row still points at (record first, delete fails). A + * publish moves STAGING -> RECEIVED: do it by id alone and a row that moved on + * gets dragged back to RECEIVED and re-read, which for a BOOKED row is a second + * Purchase. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { RECOVERABLE_PARK_REASONS } from "../src/lib/receipt-intake/stored-object"; +import { + rejectRowAndQueueCleanup, + type RejectClient, + type RejectTxClient, +} from "../src/lib/receipt-intake/storage-cleanup"; + +const ROOT = path.resolve(__dirname, ".."); +const intake = readFileSync(path.join(ROOT, "src/app/api/receipts/intake/route.ts"), "utf8"); +const finalize = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", +); + +type Row = Record; + +interface Store { + rows: Row[]; + events: { id: string; data: Record }[]; + committed: boolean; +} + +const parked = (over: Row = {}): Row => ({ + id: "row-1", + state: "STAGING", + stateReason: null, + claimToken: null, + storagePath: "receipts/intake/row-1.v1.bin", + // The upload lease this decision was reached about. A resumed /start bumps + // it, which is what makes a stale verdict land on nothing. + uploadLeaseVersion: 1, + uploadUrlExpiresAt: null, + createdAt: new Date("2026-09-01T00:00:00.000Z"), + ...over, +}); + +/** + * A $transaction that really rolls back, over rows that really match a where. + * + * The fence is the whole subject here, so the fake has to evaluate it rather + * than match on the id like the code used to. + */ +function client(rows: Row[], onTx?: (store: Store) => void): { db: RejectClient; store: Store } { + const store: Store = { rows: rows.map(r => ({ ...r })), events: [], committed: false }; + const db: RejectClient = { + $transaction: async fn => { + // A concurrent writer, running between the caller's read and this + // transaction — the race this fence exists for. + onTx?.(store); + let staged = store.rows.map(r => ({ ...r })); + const stagedEvents: Store["events"] = []; + let seq = 0; + const tx: RejectTxClient = { + automationEvent: { + create: async ({ data }) => { + const id = `ev-${++seq}`; + stagedEvents.push({ id, data }); + return { id }; + }, + }, + receiptIntake: { + findUnique: async ({ where }) => staged.find(row => row.id === where.id) ?? null, + deleteMany: async ({ where }) => { + const matches = staged.filter(row => + Object.entries(where).every(([k, v]) => row[k] === v)); + staged = staged.filter(row => !matches.includes(row)); + return { count: matches.length }; + }, + }, + }; + const out = await fn(tx); + store.rows = staged; + store.events.push(...stagedEvents); + store.committed = true; + return out; + }, + }; + return { db, store }; +} + +test("a reject deletes the row and queues the object in ONE transaction", async () => { + const { db, store } = client([parked()]); + const injected = await rejectRowAndQueueCleanup(parked() as never, "unsupported-type", db); + assert.equal(injected.ok, true); + assert.deepEqual(store.rows, [], "the row is gone"); + assert.equal(store.events.length, 1, "and exactly one cleanup is queued"); + assert.equal(store.events[0].data.status, "pending"); + assert.match(String(store.events[0].data.detail), /receipts\/intake\/row-1\.v1\.bin/); +}); + +test("PUBLISH vs REJECT: a row published mid-reject is not deleted, and nothing is queued", async () => { + // The race: /finalize inspects the object, decides it is unsupported, and + // in that window a concurrent publisher (another finalize, or the sweeper) + // moves the row to RECEIVED and seals its object to the canonical path. + // Deleting by id would destroy a PUBLISHED receipt and queue its live + // bytes for deletion. + const { db, store } = client([parked()], s => { + s.rows = [parked({ state: "RECEIVED", storagePath: "receipts/row-1/abc.png" })]; + }); + const result = await rejectRowAndQueueCleanup(parked() as never, "unsupported-file-type", db); + assert.equal(result.ok, false); + assert.equal(store.committed, false, "the whole transaction rolled back"); + assert.deepEqual(store.events, [], "no cleanup naming a path a live row points at"); + assert.equal(store.rows.length, 1, "the published row survives"); + assert.equal(store.rows[0].state, "RECEIVED"); +}); + +test("a row re-parked or claimed mid-reject also loses the fence", async () => { + for (const moved of [ + parked({ stateReason: "file-missing", state: "NEEDS_REVIEW" }), + parked({ claimToken: "worker-1" }), + parked({ storagePath: "receipts/intake/row-1-other.bin" }), + ]) { + const { db, store } = client([parked()], s => { s.rows = [moved]; }); + const result = await rejectRowAndQueueCleanup(parked() as never, "empty-file", db); + assert.equal(result.ok, false, JSON.stringify(moved)); + assert.deepEqual(store.events, [], "nothing queued"); + assert.equal(store.rows.length, 1, "nothing deleted"); + } +}); + +test("a row that is already GONE is not treated as a successful reject", async () => { + // An absent row is a row somebody else accounted for. Queueing its path for + // deletion here is exactly how a live object gets swept: the retry of a + // reject can arrive after the id was reused by a re-created row, or after + // the object was sealed under a path a new row points at. + const { db, store } = client([]); + const result = await rejectRowAndQueueCleanup(parked() as never, "unsupported-type", db); + assert.equal(result.ok, false); + assert.deepEqual(store.events, []); +}); + +test("a lost reject fence answers 409 publish-conflict and keeps the object", () => { + const branch = finalize.slice(finalize.indexOf("const rejected = await rejectRowAndQueueCleanup")); + const head = branch.slice(0, branch.indexOf("settleQueuedCleanup")); + // The reject is fenced on what was OBSERVED, so losing it means the row is + // not ours to reject — a 409 the caller can retry, never a 2xx and never a + // deletion. + assert.match(head, /state: row\.state/); + assert.match(head, /stateReason: row\.stateReason/); + assert.match(head, /storagePath: row\.storagePath/); + assert.match(head, /publish-conflict/); + assert.match(head, /status: 409/); + assert.ok( + !/deleteObjectOrRecord|removeSecureDoc/.test(head), + "no object deletion on the lost-fence path", + ); +}); + +test("publishing STAGING -> RECEIVED is fenced on the exact state", () => { + const fn = intake.slice(intake.indexOf("async function publishStagedRow")); + const body = fn.slice(0, fn.indexOf("\n/**")); + assert.match(body, /updateMany/, "not a bare update by id"); + assert.match( + body, + /where: \{ id, state: expectState, storagePath: expectStoragePath \}/, + "fenced on state AND the exact object the caller verified, not state alone", + ); + assert.match(body, /alreadyPublished: true/, "an already-RECEIVED row is the outcome we wanted"); + assert.match(body, /publish-conflict/); +}); + +test("a losing publish cleans up its own object when the winner published a different path", () => { + // Two concurrent replays of the same bytes each upload to their OWN random + // path (see /start), then both call publishStagedRow. Whichever loses the + // CAS must not just report the winner's RECEIVED outcome and walk away — + // its own upload is now unreferenced by any row, and nothing else will + // ever find it to clean it up. + const fn = intake.slice(intake.indexOf("async function publishStagedRow")); + const body = fn.slice(0, fn.indexOf("\n/**")); + const conflictBranch = body.slice(body.indexOf("if (count === 0)")); + assert.match(conflictBranch, /storagePath: true/, "re-reads the winner's actual storagePath, not just state"); + assert.match( + conflictBranch, + /current\?\.storagePath && current\.storagePath !== expectStoragePath/, + "only cleans up when the winner published somewhere else", + ); + // And the RESULT is checked. deleteObjectOrRecord throws when it can + // neither delete the object nor record it, and this branch has no + // transaction to roll back — so that failure has to reach the client as a + // retryable 503 rather than be discarded on the way to `alreadyPublished`, + // which a forwarder treats as permission to delete its only copy. + assert.match( + conflictBranch, + /deleteObjectOrRecord\(\s*\n?\s*expectStoragePath,\s*\n?\s*"orphaned-by-concurrent-publish",\s*\n?\s*\)/, + ); + assert.match(conflictBranch, /reason: "storage-unavailable", retryable: true/); + // Cleanup must happen BEFORE the idempotent success is returned — the + // finding was specifically that the loser reported success and never + // cleaned up its own path. + assert.ok( + conflictBranch.indexOf("deleteObjectOrRecord(expectStoragePath") < + conflictBranch.indexOf("alreadyPublished: true"), + "cleanup runs before the idempotent success is returned", + ); +}); + +test("recovery is restricted to the two reasons a re-upload can actually fix", () => { + // "Any NEEDS_REVIEW row" would drag a row parked for a vendor mismatch, a + // zero total, or a QBO fault back to RECEIVED and re-read it, discarding a + // decision a human had already made. + // ONE list, in the lib, asked by both publishers — two copies is how they + // come to disagree about whether a human's decision can be overwritten. + assert.deepEqual(RECOVERABLE_PARK_REASONS, ["file-missing", "sha-mismatch"]); + assert.match(intake, /finalizeDisposition\(existing\) === "publish"/); + assert.match(finalize, /finalizeDisposition\(row\)/, "the finalize route asks the same rule"); + for (const source of [intake, finalize]) { + assert.ok(!/"file-missing" \|\| /.test(source), "no hand-rolled copy of the list"); + } +}); + +test("a heal that loses its CAS deletes the object it just uploaded", () => { + // The upload happened before the CAS. Losing the race means nothing + // references those bytes, and the row we were healing belongs to somebody + // else now. + const heal = intake.slice(intake.indexOf("const healed = await storeObject")); + const body = heal.slice(0, heal.indexOf("return NextResponse.json({\n ok: true, recovered: true")); + assert.match(body, /if \(count === 0\)/); + assert.match( + body, + /deleteObjectOrRecord\(\s*\n?\s*payload\.storagePath,\s*\n?\s*"heal-lost-race",\s*\n?\s*\)/, + ); + // Same rule as the publish-race drop: an unrecordable orphan is a 503, not + // a quiet 409 that loses the bytes. + assert.match(body, /reason: "storage-unavailable", retryable: true/); + assert.match(body, /publish-conflict/); + assert.ok( + body.indexOf("payload.storagePath !== existing.storagePath") < body.indexOf("heal-lost-race"), + "and never deletes the path the surviving row still points at", + ); +}); + +// ── /start re-arms a recoverable park instead of claiming we hold it ──────── + +const start = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/start/route.ts"), + "utf8", +); + +test("/start hands a recoverable park a NEW url, and asks the shared rule which parks those are", () => { + // Answering alreadyReceived here told the forwarder we held a receipt we + // did not hold — and it deletes its only copy on that answer. + assert.match(start, /finalizeDisposition\(existing\) === "publish"/); + const branch = start.slice(start.indexOf("if (recoverable) {")); + const body = branch.slice(0, branch.indexOf("// IDENTITY MUST BE PROVEN")); + assert.match(body, /recovered: true/); + assert.match(body, /expectedSha256,/, "the sha the client is about to upload is re-armed"); + assert.match(body, /fileSha256: "",/, "and the stale stored hash is cleared"); + // Fenced like every other publish-path write, and a lost fence writes + // nothing rather than pointing a live row at an empty path. + // The fence is no longer built at the call site at all: `repathWithCleanup` + // takes the OBSERVED ROW and builds it from leaseFence itself, so neither + // /start branch can hand in half a lease identity — and the Prisma call + // names the builder rather than an opaque parameter, which is what makes it + // visible to the tripwire in receipt-intake-lease-fence.test.ts. + assert.match(body, /await repathWithCleanup\(\s*\n?\s*existing,/); + assert.match(start, /where: \{ id: existing\.id, \.\.\.leaseFence\(existing\) \},/); + assert.match(body, /return leaseConflict\(existing\.id\)/); +}); + +test("A LIVE LEASE SURVIVES A RECOVERABLE RETRY — same path, same version, no delete", () => { + // The re-arm below it is destructive by design (new version, new path, the + // old object deleted) and it used to run on EVERY /start for a parked row, + // including one whose signed URL was still live. Two retries for the same + // parked sourceRef therefore raced: the second deleted the object the first + // was about to PUT its bytes to. An earlier round fixed exactly this for + // STAGING rows and left the recovery path alone — so the rule is now ONE + // rule, in one module, and both callers reach it. + const branch = start.slice(start.indexOf("if (recoverable) {")); + const body = branch.slice(0, branch.indexOf("// IDENTITY MUST BE PROVEN")); + const reuse = body.indexOf("await reuseLiveLease(existing, ext, leaseDepsFor(deadline), {"); + assert.ok(reuse > 0, "the recovery asks the shared rule first"); + assert.ok( + reuse < body.indexOf("const nextLease = existing.uploadLeaseVersion + 1"), + "BEFORE it bumps the version", + ); + assert.ok( + reuse < body.indexOf("start-rearmed-repath"), + "and before anything is deleted", + ); + // The recovery's OWN state writes still happen -- they just land on the + // SAME path and lease version. Its IDENTITY writes no longer ride along: + // `expectedSha256` and the declared mime are part of a live lease's + // identity, and writing them through an extension is how two callers came + // to hold one generation for two different documents. The hash is passed + // as the rule's own argument now, and COMPARED. + const patch = body.slice(reuse, body.indexOf("if (keptRecovery)")); + assert.match(patch, /fileSha256: "",/); + assert.ok( + !/expectedSha256,\s*$/m.test(patch), + "the announced hash is not written through the extension", + ); + assert.match(patch, /\}, expectedSha256\);/, "it is handed to the rule to check"); + assert.ok(!/mimeType,/.test(patch), "nor the declared mime"); + assert.ok(!/uploadLeaseVersion/.test(patch), "the version is NOT touched"); + assert.ok(!/storagePath/.test(patch), "and neither is the path"); +}); + +test("both /start branches take the live-lease rule from the SAME place", () => { + // Two copies of "may I reuse this lease" is how the STAGING path came to be + // fixed while the recovery path stayed broken. + assert.equal((start.match(/await reuseLiveLease\(/g) ?? []).length, 2, "recovery and resume"); + assert.match(start, /import \{\s*\n\s*discardUnresumedLease,\s*\n\s*issuedLeaseIsCurrent,\s*\n\s*newLeaseNonce,\s*\n\s*reuseLiveLease,\s*\n\} from "@\/lib\/receipt-intake\/upload-lease";/); + // And one 409 helper, so every lost claim answers identically. SIX call + // sites on the existing row now: the two reuse callers, the two + // new-lease claims, and the two post-sign revalidations added in round + // 19 -- plus one on the created row, in the create branch. + assert.equal( + (start.match(/return leaseConflict\(existing\.id\)/g) ?? []).length, + 6, + "reuse, new-lease claim and post-sign revalidation, on both branches", + ); + // The create branch answers it twice as well, on the row it made: once + // when the signer-failure discard finds somebody else resumed the row + // (`leaseConflict(id)`, before `created` is in scope), and once when its + // own post-sign re-read finds the lease already superseded. + assert.equal( + (start.match(/return leaseConflict\(created\.id\)/g) ?? []).length, + 1, + "the create branch revalidates its own lease before answering", + ); + assert.equal( + (start.match(/return leaseConflict\(id\);/g) ?? []).length, + 1, + "and defers to the request that resumed its row", + ); + assert.match(start, /error: "publish-conflict",/, "which is still a publish-conflict"); +}); + +test("the re-arm branch runs BEFORE the identity check, and only for a parked row", () => { + // For a STAGING row the sha check still stands: it is what stops receipt B + // from being handed a URL over receipt A's verified bytes. + assert.match(start, /existing\.state !== "STAGING"\s*\n\s*&& finalizeDisposition\(existing\) === "publish"/); + assert.ok( + start.indexOf("if (recoverable) {") < start.indexOf("const knownSha ="), + "the recoverable branch is taken before the identity check", + ); + assert.match(start, /alreadyReceived: true/, "everything else still answers alreadyReceived"); +}); + +test("a re-arm that changes the extension does not orphan the old object", () => { + const branch = start.slice(start.indexOf("if (recoverable) {")); + const body = branch.slice(0, branch.indexOf("// IDENTITY MUST BE PROVEN")); + // Guarded, SCHEDULED and ATOMIC, all in the shared helper: an extension + // change is precisely the case that reaches this branch with the OLD + // path's signed URL still live, so the delete waits for that URL to die + // rather than racing a late PUT — and the queue entry that remembers the + // object commits with the repath rather than after it. + assert.match(body, /repathWithCleanup\(\s*\n?\s*existing,/); + assert.match(body, /"start-rearmed-repath",/); + const helper = start.slice(start.indexOf("async function repathWithCleanup")); + assert.match(helper, /nextPath !== existing\.storagePath/); + assert.match(helper, /cleanupNotBefore\(existing\)/); +}); + +// ── The sweeper rejects through the same fenced transaction ──────────────── + +const sweeper = readFileSync( + path.join(ROOT, "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", +); + +test("SWEEPER RACE: a publish that wins mid-sweep leaves the row and the bytes alone", async () => { + // The sweep reads a batch, then spends a storage round trip per row + // deciding what to do with it. A /finalize arriving in that window can + // publish the row and seal its object — and the old unfenced + // `deleteMany({ id, state: "STAGING" })` plus a bare object delete would + // then destroy a published receipt's row OR its bytes, depending on + // timing. The reject transaction is what makes that a no-op. + const { db, store } = client([parked()], s => { + s.rows = [parked({ state: "RECEIVED", storagePath: "receipts/row-1/sealed.png" })]; + }); + let deletedBytes = 0; + const dropped = await rejectRowAndQueueCleanup(parked() as never, "unsupported-file-type", db); + if (dropped.ok) deletedBytes++; // the caller only touches storage on success + assert.equal(dropped.ok, false, "the fence lost"); + assert.equal(deletedBytes, 0, "so no object was deleted"); + assert.deepEqual(store.events, [], "and nothing was queued for deletion"); + assert.equal(store.rows[0].state, "RECEIVED", "the published row is untouched"); +}); + +test("the sweeper uses the fenced reject, and touches no bytes when it loses", () => { + const fn = sweeper.slice(sweeper.indexOf("sweepStaleStaging: async")); + const body = fn.slice(0, fn.indexOf("loadPhases:")); + assert.match(body, /const dropped = await rejectRowAndQueueCleanup\(/); + assert.match(body, /if \(!dropped\.ok\) continue;/); + // The schedule rides along even though it is always null here — the + // `leaseLive` guard above already refused to reject a row whose URL still + // works, and stating the rule beats relying on a check twenty lines up. + assert.match( + body, + /settleQueuedCleanup\(dropped\.eventId, row\.storagePath, cleanupNotBefore\(row\)\)/, + ); + // The unfenced pair this replaces. + assert.ok( + !/deleteMany\(\{ where: \{ id: row\.id, state: "STAGING" \} \}\)/.test(body), + "no delete-by-id-and-state", + ); + assert.ok( + body.indexOf("if (!dropped.ok) continue;") < body.lastIndexOf("settleQueuedCleanup"), + "the object is only touched after the row is provably gone", + ); +}); + +test("nothing destructive happens while the upload lease is live", () => { + const fn = sweeper.slice(sweeper.indexOf("sweepStaleStaging: async")); + const body = fn.slice(0, fn.indexOf("loadPhases:")); + // Three destructive outcomes, three lease checks: sha-mismatch park, + // file-missing park, and the reject. + assert.equal( + (body.match(/if \(leaseLive\) \{ leaseActive\+\+; continue; \}/g) ?? []).length, + 3, + "every destructive branch waits for the lease", + ); + // Publishing is NOT gated on it: a complete, correct object is a complete, + // correct object whether or not the URL is still live. + const publishBranch = body.slice(body.indexOf("if (check.ok) {"), body.indexOf("if (check.kind === \"transient\")")); + assert.ok(publishBranch.includes("sealAndPublish")); + assert.equal( + (publishBranch.match(/if \(leaseLive\)/g) ?? []).length, + 1, + "only the sha-mismatch park inside the ok branch waits", + ); +}); + +// ── RESUME vs REJECT: the upload lease version decides (round-14 item 1) ─── + +test("a client that RESUMES its upload mid-sweep is not rejected for the old one", async () => { + // The real interleaving: the sweep reads a stale STAGING row, spends a + // storage round trip on the object the client abandoned, and decides to + // reject. In that window /start hands the client a fresh URL — a new lease + // version, a new path, a live expiry. Without the version in the fence the + // sweep deletes the row (and queues its object) for a receipt that is + // actively being uploaded, and the forwarder is told nothing. + const observed = parked({ uploadLeaseVersion: 1, storagePath: "receipts/intake/row-1.v1.bin" }); + const { db, store } = client([observed], s => { + s.rows = [parked({ + uploadLeaseVersion: 2, + storagePath: "receipts/intake/row-1.v2.bin", + uploadUrlExpiresAt: new Date(Date.now() + 60 * 60_000), + })]; + }); + + const dropped = await rejectRowAndQueueCleanup(observed as never, "unsupported-file-type", db); + assert.equal(dropped.ok, false, "the fence lost to the newer lease"); + assert.equal(store.committed, false); + assert.deepEqual(store.events, [], "the v1 object is not queued for deletion by this pass"); + assert.equal(store.rows.length, 1, "and the row survives"); + assert.equal(store.rows[0].uploadLeaseVersion, 2); +}); + +test("a lease that came back to life inside the transaction aborts the reject", async () => { + // The version alone cannot see this one: /start refreshed the EXPIRY on the + // same lease. The verifier runs on a row re-read inside the transaction, + // which is the only place that is true. + const observed = parked(); + const { db, store } = client([observed], s => { + s.rows = [parked({ uploadUrlExpiresAt: new Date(Date.now() + 60 * 60_000) })]; + }); + const dropped = await rejectRowAndQueueCleanup( + observed as never, + "unsupported-file-type", + db, + fresh => (fresh.uploadUrlExpiresAt as Date | null) && + (fresh.uploadUrlExpiresAt as Date).getTime() > Date.now() + ? "upload-lease-active" + : null, + ); + assert.equal(dropped.ok, false); + assert.deepEqual(store.events, []); + assert.equal(store.rows.length, 1); +}); + +test("an unchanged lease still rejects — the control", async () => { + const observed = parked(); + const { db, store } = client([observed]); + const dropped = await rejectRowAndQueueCleanup(observed as never, "unsupported-file-type", db, () => null); + assert.equal(dropped.ok, true); + assert.deepEqual(store.rows, []); + assert.equal(store.events.length, 1); +}); + +test("the sweeper and /start both fence on the lease version", () => { + const start = readFileSync( + path.join(ROOT, "src/app/api/receipts/intake/start/route.ts"), + "utf8", + ); + // /start claims the new lease BEFORE it signs anything: the version, the + // expiry and the path move in ONE checked update, and a lost update is a + // 409 rather than a URL for a row somebody else has moved on. + assert.equal((start.match(/uploadLeaseVersion: nextLease/g) ?? []).length, 2, "resume and re-arm"); + assert.equal((start.match(/const nextLease = existing\.uploadLeaseVersion \+ 1/g) ?? []).length, 2); + // The move now runs inside repathWithCleanup — one transaction carrying + // both the fenced update and the abandoned object's cleanup entry — but + // the ordering property is unchanged: the row moves BEFORE anything is + // signed, so a signer failure cannot leave a URL for a row somebody else + // has moved on. + for (const branch of ["const rearmed = await signUpload(retryPath,", "const resumed = await signUpload(resumePath,"]) { + const at = start.indexOf(branch); + assert.ok(at > 0, branch); + const move = start.lastIndexOf("await repathWithCleanup(", at); + assert.ok(move > 0 && move < at, `${branch}: the row moves before the URL is signed`); + } + assert.equal( + (start.match(/await repathWithCleanup\(/g) ?? []).length, + 2, + "resume and re-arm, both through the one transactional helper", + ); + // ONE 409 helper now — four call sites (the two new-lease claims and the + // two live-lease reuses), so a lost claim cannot answer differently + // depending on which branch lost it. + assert.equal((start.match(/error: "publish-conflict"/g) ?? []).length, 1, "one helper"); + assert.equal( + (start.match(/return leaseConflict\(existing\.id\)/g) ?? []).length, + 6, + "and every lost claim goes through it", + ); + + // Every sweeper write carries the COMPLETE observed lease identity, and + // gets it from the one builder. Counting hand-rolled `uploadLeaseVersion:` + // pins is what let three of these four fence on half the identity while + // the fourth (the reject) carried all of it. + const fn = sweeper.slice(sweeper.indexOf("sweepStaleStaging: async")); + const body = fn.slice(0, fn.indexOf("loadPhases:")); + assert.equal( + (body.match(/\.\.\.leaseFence\(row\)/g) ?? []).length, + 3, + "the two parks and the publish commit", + ); + // The reject reaches the same builder through rejectRowAndQueueCleanup, + // which now builds its delete's where from leaseFence too. + assert.match(body, /cleanupNotBefore: cleanupNotBefore\(row\),/); + const cleanup = readFileSync(path.join(ROOT, "src/lib/receipt-intake/storage-cleanup.ts"), "utf8"); + assert.match(cleanup, /where: \{ id: row\.id, storagePath: row\.storagePath, \.\.\.leaseFence\(row\) \}/); + // The remaining `uploadLeaseVersion: row.uploadLeaseVersion` in this file + // is the RejectFence VALUE the sweeper hands to rejectRowAndQueueCleanup, + // not a where clause — and its type makes the list exhaustive, so a + // forgotten field is a compile error rather than a half fence. The + // where-clause rule itself is enforced for all six files by the tripwire in + // tests/receipt-intake-lease-fence.test.ts. + assert.match(body, /state: row\.state,/, "the reject fence pins the OBSERVED state"); + // ...and the reject also re-reads the row inside the transaction. + assert.match(body, /fresh => uploadLeaseActive\(/); +}); + +// ── A FAILED SIGNER MUST NOT DELETE A ROW SOMEBODY ELSE RESUMED ──────────── + +test("/start's signer-failure cleanup is a CAS over the lease it wrote, not a delete by id", () => { + // The row is created BEFORE the URL is signed, so a concurrent /start for + // the same sourceRef can hit the unique violation, adopt the row through + // reuseLiveLease and be holding a working URL by the time the original's + // signer fails. The unconditional `delete({ where: { id } })` this replaces + // then removed the shared row: the retry's bytes landed at a path nothing + // pointed at, /finalize 404'd, and the sourceRef stopped protecting the + // document's identity. The behaviour is in tests/receipt-intake-upload-lease. + assert.ok( + !/receiptIntake\.delete\(/.test(start), + "no unconditional delete is left anywhere in the route", + ); + const signedAt = start.indexOf("const signed = await signUpload(storagePath,"); + // A -1 here would slice the LAST CHARACTER of the file and every + // assertion below would then be made about one stray character. Fail + // on the anchor instead. + assert.ok(signedAt > 0, "the signer call is still where this pin thinks it is"); + const branch = start.slice(signedAt); + assert.match(branch, /await discardUnresumedLease\(/); + // The SAME values that were written to the row. A second uploadLeaseExpiry() + // call would compare a fresh instant against the stored one and never match, + // which would leak a STAGING row (and its sourceRef) on every signer fault. + assert.match(start, /const leaseExpiresAt = uploadLeaseExpiry\(\);/); + assert.match(start, /const leaseNonce = newLeaseNonce\(\);/); + assert.match(start, /uploadUrlExpiresAt: leaseExpiresAt,\n\s+uploadLeaseVersion: 1,\n\s+uploadLeaseNonce: leaseNonce,/); + // AND THE GENERATION IS IN THE CAS. Pinning the expiry alone was the hole + // in the previous round's own fix: an adoption computes "now + 2h" exactly + // as this request did, so the two can land on the same millisecond and the + // pin matches a row somebody else already owns. + const discardArgs = branch.slice(branch.indexOf("await discardUnresumedLease(")); + for (const pinned of [ + /id,/, /storagePath,/, /uploadLeaseVersion: 1,/, + /uploadUrlExpiresAt: leaseExpiresAt,/, /uploadLeaseNonce: leaseNonce,/, + ]) { + assert.match(discardArgs.slice(0, discardArgs.indexOf("prisma.receiptIntake")), pinned); + } + // A lost CAS is the idempotent conflict, never an error about a row that is + // alive and in somebody else's hands. + assert.match(branch, /if \(discarded === "resumed"\) \{/); + assert.match(branch, /return leaseConflict\(id\);/); + assert.ok( + branch.indexOf(`discarded === "resumed"`) < branch.indexOf(`reason: "storage-unavailable"`), + "the conflict is answered before the signer's own 503", + ); +}); + +// ── The cutover writes are fenced on the rows they were decided about ────── + +test("no cutover write updates by id alone", () => { + // READ COMMITTED lets an admin review (or any writer that never touches the + // claim's advisory lock) move a parked row between the SELECT that triaged + // it and the UPDATE that acts on the verdict. `where: { id: { in: [...] } }` + // then overwrote that with a TERMINAL SHADOW_* state, or handed the row to + // v2 with `dryRun: false`. Behaviour: tests/receipt-intake-cutover. + const fn = sweeper.slice(sweeper.indexOf("async function claim(")); + const body = fn.slice(0, fn.indexOf("const ELIGIBLE =")); + assert.equal( + (body.match(/await applyCutoverVerdict\(/g) ?? []).length, + 3, + "retire, quarantine and requeue all go through the fenced write", + ); + assert.ok( + !/tx\.receiptIntake\.updateMany\(/.test(body), + "and none of them writes an unfenced updateMany of its own", + ); + // The rows the verdict is applied to are the ones that were triaged, not a + // re-derived id list — that is how the two came to disagree before. + assert.match(body, /const byId = new Map\(candidates\.map\(row => \[row\.id, row\]\)\);/); + assert.match( + body, + /state: true, stateReason: true, dryRun: true, claimToken: true,/, + "the evidence each write fences on is selected with them", + ); + // Rows whose CAS lost are reported, not silently dropped. + assert.match(body, /shadowSkippedMoved \+= /); + assert.match(body, /shadowRetired, requeued, shadowQuarantined, shadowSkippedMoved,/); +}); + +// ── A /start REFRESH during an in-flight REJECT (Codex round-12 item 1) ───── +// +// The mirror of the publish race in receipt-intake-stored-object.test.ts, on +// the delete instead of the commit. reuseLiveLease reissues a working signed +// URL over the same path at the same version, writing only the nonce and the +// expiry — so a reject decided a moment earlier still matched every column the +// old fence pinned, and destroyed the only record of a receipt whose upload +// link had just been renewed. + +const REFRESHED_NONCE = "nonce-b"; +const OBSERVED_NONCE = "nonce-a"; +const EXPIRY = new Date("2026-09-03T12:00:00.000Z"); +const REFRESHED_EXPIRY = new Date(EXPIRY.getTime() + 2 * 60 * 60_000); + +test("REJECT vs REFRESH: a lease reissued mid-inspection saves the row", async () => { + // /finalize decides the object is unacceptable, and in the seconds it spent + // reading it a /start retry handed the client a working URL over the same + // path. Deleting the row now destroys the only record of an inbound receipt + // whose upload link is live. + const observed = parked({ + uploadLeaseNonce: OBSERVED_NONCE, + uploadUrlExpiresAt: new Date(Date.now() + 60 * 60_000), + }); + const { db, store } = client([observed], s => { + s.rows = [parked({ + ...observed, + uploadLeaseNonce: REFRESHED_NONCE, + uploadUrlExpiresAt: new Date(Date.now() + 3 * 60 * 60_000), + })]; + }); + + const dropped = await rejectRowAndQueueCleanup(observed as never, "unsupported-file-type", db); + + assert.equal(dropped.ok, false, "the fence lost"); + assert.equal(store.committed, false, "the whole transaction rolled back"); + assert.deepEqual(store.events, [], "nothing queued against the stale expiry"); + assert.equal(store.rows.length, 1, "and the row — the client's only record — survives"); +}); + +test("REJECT vs REFRESH control: the lease generation is what catches it", async () => { + // The version, the path, the state and the reason are all UNCHANGED across + // a refresh, so every column the old fence pinned still matched. Asserted + // directly, so this cannot pass for a fence that lost for another reason. + const observed: Row = parked({ uploadLeaseNonce: OBSERVED_NONCE, uploadUrlExpiresAt: EXPIRY }); + const refreshed: Row = { + ...observed, + uploadLeaseNonce: REFRESHED_NONCE, + uploadUrlExpiresAt: REFRESHED_EXPIRY, + }; + for (const column of ["state", "stateReason", "storagePath", "uploadLeaseVersion", "claimToken"]) { + assert.equal(refreshed[column], observed[column], `${column} survives a refresh`); + } + assert.notEqual(refreshed.uploadLeaseNonce, observed.uploadLeaseNonce); + assert.notEqual(refreshed.uploadUrlExpiresAt, observed.uploadUrlExpiresAt); + + // ...and an unrefreshed row still rejects, so the pin is not simply fatal. + const { db, store } = client([observed]); + const ok = await rejectRowAndQueueCleanup(observed as never, "unsupported-file-type", db); + assert.equal(ok.ok, true); + assert.deepEqual(store.rows, []); + assert.equal(store.events.length, 1); +}); diff --git a/tests/receipt-intake-route-state.test.ts b/tests/receipt-intake-route-state.test.ts new file mode 100644 index 000000000..4561af960 --- /dev/null +++ b/tests/receipt-intake-route-state.test.ts @@ -0,0 +1,189 @@ +/** + * The routing truth table (docs/plans/PHASE-1-INTAKE-CORE-SPEC.md §4) plus the + * booking backoff schedule. Both are pure, so this file needs no database. + * + * Order is the assertion, not just the outcomes: "first match wins" is why a + * $0 misread never reaches a dedup key and why a multi-page scan is triaged + * before anyone asks which job it belongs to. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { backoffMs, MAX_BOOK_ATTEMPTS, preservedTaxWarning, routeState } from "../src/lib/receipt-intake/route-state"; + +const NO_HITS = { strong: null, weak: null }; +const clean = { docType: "receipt", amount: "364.98", totalCents: 36498, canonicalVendor: "lowes" }; +/** The strong key is vendor-LESS, so an owner has to carry its vendor separately. */ +const owner = (over: Partial<{ id: string; totalCents: number | null; canonicalVendor: string | null }> = {}) => + ({ id: "row-a", totalCents: 36498, canonicalVendor: "lowes", ...over }); + +test("multi outranks everything, including a missing project", () => { + const d = routeState({ docType: "multi", amount: "0.00", totalCents: null, canonicalVendor: "" }, NO_HITS, false); + assert.deepEqual(d, { state: "NEEDS_REVIEW", stateReason: "multi-doc", duplicateOfId: null }); +}); + +test("a non-receipt is its own terminal state, not a review item", () => { + const d = routeState({ docType: "non_receipt", amount: "0.00", totalCents: null, canonicalVendor: "" }, NO_HITS, true); + assert.deepEqual(d, { state: "NON_RECEIPT", stateReason: null, duplicateOfId: null }); +}); + +test("a $0.00 total is a misread and is parked BEFORE any dedup or job check", () => { + // :531 — you don't get a $0 receipt or write a $0 check. Letting this reach + // a key would poison it for the real document. + const d = routeState( + { docType: "receipt", amount: "0.00", totalCents: 0, canonicalVendor: "lowes" }, + { strong: owner({ id: "owner", totalCents: 0 }), weak: { id: "other" } }, + true, + ); + assert.deepEqual(d, { state: "NEEDS_REVIEW", stateReason: "refund-or-zero", duplicateOfId: null }); +}); + +test("a NEGATIVE total is a refund: reviewed, and it claims no dedup key", () => { + // A refund is a legitimate document — v1 carried them all the way through + // rename/dedup/archive — but it must never book itself against the original + // purchase automatically, and it must not hold a key the original needs. + for (const [amount, cents] of [["-22.57", -2257], ["-1200.00", -120000]] as const) { + const d = routeState( + { docType: "receipt", amount, totalCents: cents, canonicalVendor: "lowes" }, + NO_HITS, + true, + ); + assert.deepEqual(d, { state: "NEEDS_REVIEW", stateReason: "refund-or-zero", duplicateOfId: null }, amount); + } +}); + +test("an unreadable total (null cents) is reviewed, not booked", () => { + const d = routeState( + { docType: "receipt", amount: "abc", totalCents: null, canonicalVendor: "lowes" }, + NO_HITS, + true, + ); + assert.equal(d.stateReason, "refund-or-zero"); +}); + +test("no project means NEEDS_JOB — a queue, not a fault", () => { + const d = routeState(clean, NO_HITS, false); + assert.deepEqual(d, { state: "NEEDS_JOB", stateReason: null, duplicateOfId: null }); +}); + +test("a strong hit at the same total AND the same vendor is the same purchase twice", () => { + const d = routeState(clean, { strong: owner(), weak: null }, true); + assert.deepEqual(d, { state: "DUPLICATE", stateReason: null, duplicateOfId: "row-a" }); +}); + +test("same total, DIFFERENT vendor is a key collision, not a duplicate", () => { + // The v3.6 key leaves the vendor out on purpose (one store spells its own + // name three ways). The cost is that two unrelated vendors reusing an + // invoice number on one day for the same amount collide — and quarantining + // one of those would silently drop a real expense. The vendor is not part + // of the KEY, but it is part of the CONFIRMATION. + const d = routeState(clean, { strong: owner({ canonicalVendor: "homedepot" }), weak: null }, true); + assert.deepEqual(d, { + state: "NEEDS_REVIEW", + stateReason: "vendor-mismatch:row-a", + duplicateOfId: "row-a", + }); +}); + +test("an owner whose VENDOR is unknown is not a confirmed match either", () => { + const d = routeState(clean, { strong: owner({ canonicalVendor: null }), weak: null }, true); + assert.equal(d.state, "NEEDS_REVIEW"); + assert.equal(d.stateReason, "vendor-mismatch:row-a"); +}); + +test("a chain's spelling variants still collapse — canonicalVendor is what is compared", () => { + // "Lowe's Home Improvement" and "LOWES HOME CENTERS LLC" both canonicalise + // to "lowes", so the alias table (not the raw string) decides this. + const d = routeState(clean, { strong: owner({ canonicalVendor: "lowes" }), weak: null }, true); + assert.equal(d.state, "DUPLICATE"); +}); + +test("a strong hit at a DIFFERENT total is ambiguous and goes to a human", () => { + const d = routeState(clean, { strong: owner({ totalCents: 20000 }), weak: null }, true); + assert.deepEqual(d, { + state: "NEEDS_REVIEW", + stateReason: "strong-dup-amount-mismatch:row-a", + duplicateOfId: "row-a", + }); +}); + +test("an owner whose total is unknown is never treated as a match", () => { + // A null total means "can't confirm the totals match" — reading it as a + // match would silently quarantine a real expense. + const d = routeState(clean, { strong: owner({ totalCents: null }), weak: null }, true); + assert.equal(d.state, "NEEDS_REVIEW"); + assert.equal(d.stateReason, "strong-dup-amount-mismatch:row-a"); +}); + +test("a weak hit always asks a human, never quarantines on its own", () => { + const d = routeState(clean, { strong: null, weak: { id: "row-b" } }, true); + assert.deepEqual(d, { state: "NEEDS_REVIEW", stateReason: "weak-dup:row-b", duplicateOfId: null }); +}); + +test("the strong net is checked before the weak one", () => { + const d = routeState(clean, { strong: owner(), weak: { id: "row-b" } }, true); + assert.equal(d.state, "DUPLICATE"); + assert.equal(d.duplicateOfId, "row-a"); +}); + +test("a clean document with a job and no hits is READ", () => { + assert.deepEqual(routeState(clean, NO_HITS, true), { + state: "READ", stateReason: null, duplicateOfId: null, + }); +}); + +test("the tax warning is read from its OWN column, not from stateReason", () => { + // THE ROUND-20 FINDING. Routing wrote the marker into `stateReason`, and + // a deferred booking then replaced that column with `push-disabled` or + // `push-paused` -- which is EVERY row during the disabled-push cutover. + // The BOOKED transition read the marker out of whatever the column held at + // that moment, so the evidence was already gone. It has its own column + // now, written once by routing and touched by nothing else. + assert.equal( + preservedTaxWarning({ taxWarning: "tax-implausible", stateReason: "push-disabled" }), + "tax-implausible", + "a deferred booking cannot erase it", + ); + assert.equal( + preservedTaxWarning({ taxWarning: "tax-implausible", stateReason: null }), + "tax-implausible", + ); + assert.equal( + preservedTaxWarning({ taxWarning: null, stateReason: "push-paused" }), + null, + "and a defer reason is still not a warning", + ); + + // PRE-FIX CONTROL: reading `stateReason` alone loses it the moment a + // defer reason lands there. This is the shipped behaviour, restated. + assert.equal( + preservedTaxWarning({ stateReason: "push-disabled" }), + null, + "the old source of truth says the receipt had a clean tax read", + ); + + // THE FALLBACK, for rows already mid-flight when the column was added: one + // sitting in BOOKING with the marker in the old place must not lose it at + // deploy time. + assert.equal( + preservedTaxWarning({ stateReason: "tax-implausible" }), + "tax-implausible", + ); + assert.equal( + preservedTaxWarning({ stateReason: "weak-dup:row-a;tax-implausible" }), + "tax-implausible", + "including a compound reason, the way note() builds one", + ); + assert.equal(preservedTaxWarning({}), null); + assert.equal(preservedTaxWarning({ taxWarning: null, stateReason: null }), null); + // A column carrying something ELSE is not the marker. + assert.equal(preservedTaxWarning({ taxWarning: "something-else" }), null); +}); + +test("backoff is 5m / 15m / 1h / 6h and then stays at 6h", () => { + assert.equal(backoffMs(1), 5 * 60_000); + assert.equal(backoffMs(2), 15 * 60_000); + assert.equal(backoffMs(3), 60 * 60_000); + assert.equal(backoffMs(4), 6 * 60 * 60_000); + assert.equal(backoffMs(10), 6 * 60 * 60_000); + assert.equal(MAX_BOOK_ATTEMPTS, 20); +}); diff --git a/tests/receipt-intake-stored-object.test.ts b/tests/receipt-intake-stored-object.test.ts new file mode 100644 index 000000000..578291517 --- /dev/null +++ b/tests/receipt-intake-stored-object.test.ts @@ -0,0 +1,1319 @@ +/** + * The shared stored-object validator. + * + * TWO callers publish a STAGING row — /intake/{id}/finalize and the worker's + * stale-STAGING sweep. They must agree, or whichever runs first decides whether + * a 40 MB video becomes a receipt. This is that agreement, in one place. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + canonicalStoragePath, + declaredShaConflict, + downloadVerified, + finalizeDisposition, + inspectStoredObject, + leaseFence, + publishFence, + type ObservedRow, + RECOVERABLE_PARK_REASONS, + sealAndPublish, + verifyStoredCopy, +} from "../src/lib/receipt-intake/stored-object"; +import { MAX_STORED_BYTES } from "../src/lib/receipt-intake/intake-core"; +import { receiptObjectSize } from "../src/lib/receipt-intake/bucket"; +import type { DocBytesResult } from "../src/lib/secure-storage"; + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); +const give = (r: DocBytesResult) => async () => r; +/** A metadata size that says "small, and definitely there". */ +const sized = (size: number) => async () => ({ ok: true as const, size }); +const SMALL = sized(1); +/** + * inspectStoredObject reads the size from METADATA before it reads a body, and + * its default lookup talks to Supabase. Every case here supplies both stubs, so + * a test can never accidentally exercise the real bucket. + */ +const inspect = ( + path: string, + mime: string, + download: Parameters[3], + size: Parameters[4] = SMALL, +) => inspectStoredObject(path, mime, undefined, download, size); + +test("a real image is accepted, and its metadata comes from the BYTES", async () => { + const check = await inspect("p.jpg", "application/pdf", give({ ok: true, bytes: PNG })); + assert.ok(check.ok); + // The declared type said PDF. The bytes say PNG, and the bytes win. + assert.equal(check.mimeType, "image/png"); + assert.equal(check.fileSize, PNG.length); + assert.equal(check.fileSha256, createHash("sha256").update(PNG).digest("hex")); +}); + +test("oversize, empty and unidentifiable objects are REJECTED, not published", async () => { + // The signed upload URL bypassed every check the server could otherwise + // make, so these are enforced on the object itself. + const big = await inspect("p.jpg", "image/jpeg", give({ + ok: true, bytes: Buffer.alloc(MAX_STORED_BYTES + 1, 1), + })); + assert.equal(big.ok, false); + assert.match((big as { reason: string }).reason, /^file-too-large:/); + + const empty = await inspect("p.jpg", "image/jpeg", give({ ok: true, bytes: Buffer.alloc(0) })); + assert.equal((empty as { reason: string }).reason, "empty-file"); + + const exe = await inspect("p.exe", "image/jpeg", give({ ok: true, bytes: Buffer.from("MZ\x90\x00") })); + assert.equal((exe as { reason: string }).reason, "unsupported-file-type"); +}); + +test("an oversize object is rejected from METADATA, with no body read at all", async () => { + // The signed upload URL bypasses this server, so the first time anything + // here sees the object is now. Downloading it to discover it is 400 MB is + // how one upload takes the whole invocation — and its memory — with it. + let downloads = 0; + const check = await inspect( + "p.bin", + "image/jpeg", + async () => { downloads++; throw new Error("the body must never be fetched"); }, + sized(MAX_STORED_BYTES + 1), + ); + assert.equal(check.ok, false); + assert.equal((check as { reason: string }).reason, `file-too-large:${MAX_STORED_BYTES + 1}`); + assert.equal(downloads, 0, "not one byte was read"); +}); + +test("an UNKNOWN metadata size is TRANSIENT, and still reads no body", async () => { + // "Storage did not say" used to mean "carry on and let the byte-length + // check catch it" — which is the download this call exists to avoid, taken + // on exactly the objects we know least about. Both callers retry a + // transient answer; neither is harmed by waiting, and both are harmed by a + // 400 MB read. + let downloads = 0; + const check = await inspect( + "p.png", + "image/png", + async () => { downloads++; throw new Error("the body must never be fetched"); }, + async () => ({ ok: false as const, kind: "transient" as const, message: "size-unavailable" }), + ); + assert.equal(check.ok, false); + assert.equal((check as { kind: string }).kind, "transient"); + assert.equal(downloads, 0, "not one byte was read"); +}); + +test("a size lookup that says MISSING is missing, and reads no body either", async () => { + // An empty listing is an answer. Downloading to rediscover a 404 is a + // round trip that can only produce the same verdict. + let downloads = 0; + const check = await inspect( + "p.png", + "image/png", + async () => { downloads++; throw new Error("the body must never be fetched"); }, + async () => ({ ok: false as const, kind: "missing" as const }), + ); + assert.equal((check as { kind: string }).kind, "missing"); + assert.equal(downloads, 0); +}); + +test("a metadata size AT the ceiling is not rejected before the download", async () => { + let downloads = 0; + const check = await inspect( + "p.png", + "image/png", + async () => { downloads++; return { ok: true as const, bytes: PNG }; }, + sized(MAX_STORED_BYTES), + ); + assert.ok(check.ok); + assert.equal(downloads, 1); +}); + +test("exactly at the ceiling is allowed", async () => { + const atLimit = Buffer.concat([PNG, Buffer.alloc(MAX_STORED_BYTES - PNG.length, 0)]); + assert.equal(atLimit.length, MAX_STORED_BYTES); + const check = await inspect("p.png", "image/png", give({ ok: true, bytes: atLimit })); + assert.ok(check.ok, "the boundary itself is not oversize"); +}); + +test("missing and transient are DIFFERENT answers", async () => { + // A confirmed 404 is terminal for the sweep; a storage blip must come back + // next pass rather than park a good receipt as file-missing. + const missing = await inspect("p.jpg", "image/jpeg", give({ ok: false, kind: "not-found" })); + assert.deepEqual(missing, { ok: false, kind: "missing" }); + + const flaky = await inspect("p.jpg", "image/jpeg", give({ + ok: false, kind: "transient", message: "ECONNRESET", + })); + assert.deepEqual(flaky, { ok: false, kind: "transient", message: "ECONNRESET" }); +}); + +// ── Sealing and re-verification (round-8 item 2) ─────────────────────────── + +test("the canonical path is content-addressed and per-row", () => { + // The client is never given a URL for this path, and its NAME asserts the + // content — so a later comparison is against a value that cannot have been + // rewritten in place. + const sha = createHash("sha256").update(PNG).digest("hex"); + assert.equal(canonicalStoragePath("row-1", 1, sha, "image/png"), `receipts/row-1/v1/${sha}.png`); + assert.equal(canonicalStoragePath("row-1", 1, sha, "application/pdf"), `receipts/row-1/v1/${sha}.pdf`); + // Two rows with identical bytes still get separate objects — deleting one + // receipt must never remove another's evidence. + assert.notEqual( + canonicalStoragePath("row-1", 1, sha, "image/png"), + canonicalStoragePath("row-2", 1, sha, "image/png"), + ); +}); + +test("the canonical path carries the UPLOAD LEASE, so a re-seal never reuses a queued-for-deletion path", () => { + // A path that is a function of the row and the bytes alone is reused by + // every later attempt on the same row — including one that follows a + // rejection, and a rejection is what QUEUES A DELETION of that exact path. + // A re-armed /start bumps the lease, so the next publish targets a path no + // outstanding cleanup event can be naming. + const sha = createHash("sha256").update(PNG).digest("hex"); + assert.notEqual( + canonicalStoragePath("row-1", 1, sha, "image/png"), + canonicalStoragePath("row-1", 2, sha, "image/png"), + ); +}); + +test("a download whose bytes do not match the recorded sha is REFUSED", async () => { + // THE OVERWRITE ATTACK. The upload path is writable by whoever holds the + // signed URL (upsert, deliberately). If the row still pointed there, the + // verified content could be swapped afterwards and the row would keep + // asserting the old sha while storage served something else. + const realSha = createHash("sha256").update(PNG).digest("hex"); + const swapped = Buffer.from("totally different bytes"); + + const good = await downloadVerified("p.png", realSha, undefined, give({ ok: true, bytes: PNG })); + assert.deepEqual(good, { ok: true, bytes: PNG }); + + const attacked = await downloadVerified("p.png", realSha, undefined, give({ ok: true, bytes: swapped })); + assert.equal(attacked.ok, false); + assert.equal((attacked as { kind: string }).kind, "sha-mismatch"); +}); + +test("missing and transient stay distinguishable through verification", async () => { + assert.deepEqual( + await downloadVerified("p.png", "x".repeat(64), undefined, give({ ok: false, kind: "not-found" })), + { ok: false, kind: "missing" }, + ); + const flaky = await downloadVerified("p.png", "x".repeat(64), undefined, give({ + ok: false, kind: "transient", message: "ECONNRESET", + })); + assert.equal((flaky as { kind: string }).kind, "transient"); +}); + +// ── "We already have it" means THIS DOCUMENT (round-33 item 3) ──────────── + +const replayRoutes = { + "POST /api/receipts/intake": readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/route.ts"), + "utf8", + ), + "POST /api/receipts/intake/{id}/finalize": readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ), + // THREE, not two. /start answers a retrying forwarder about a SETTLED row, + // and the forwarder deletes its only copy on that answer just as it does + // for the other two — but this one used to decide it from a size probe + // alone. + "POST /api/receipts/intake/start": readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/start/route.ts"), + "utf8", + ), +}; + +test("MUTATED OBJECT: a replaced object is content-mismatch, never 'we have it'", async () => { + // Both replay paths used to confirm PRESENCE and return success. The + // forwarders delete their only copy on that answer — so an object replaced + // or corrupted after publication (an upsert URL reused, a restore that put + // back a different version, a storage-side fault) was laundered into "we + // have your receipt" and the last good copy went with it. + const realSha = createHash("sha256").update(PNG).digest("hex"); + const mutated = Buffer.from(PNG); + mutated[mutated.length - 1] ^= 0xff; // one flipped bit is enough + + const held = await verifyStoredCopy("p.png", realSha, undefined, SMALL, give({ ok: true, bytes: mutated })); + assert.deepEqual( + { ok: held.ok, kind: (held as { kind?: string }).kind }, + { ok: false, kind: "content-mismatch" }, + ); +}); + +test("the control: the ORIGINAL bytes still verify", async () => { + const realSha = createHash("sha256").update(PNG).digest("hex"); + const held = await verifyStoredCopy("p.png", realSha, undefined, SMALL, give({ ok: true, bytes: PNG })); + assert.deepEqual(held, { ok: true }); +}); + +test("a mismatch is decided WITHOUT healing, and absence still reads as absence", async () => { + const realSha = createHash("sha256").update(PNG).digest("hex"); + // The metadata probe runs first, so an orphan never pays for a download — + // and never reaches the hash comparison at all. + let downloads = 0; + const counted = async () => { downloads++; return { ok: true as const, bytes: PNG }; }; + const gone = await verifyStoredCopy("p.png", realSha, undefined, async () => ({ ok: false, kind: "missing" }), counted); + assert.equal((gone as { kind: string }).kind, "missing"); + assert.equal(downloads, 0, "no body was read for an object that is not there"); + + const flaky = await verifyStoredCopy( + "p.png", realSha, undefined, + async () => ({ ok: false, kind: "transient", message: "ECONNRESET" }), + counted, + ); + assert.equal((flaky as { kind: string }).kind, "transient", "a fault is never a verdict"); + assert.equal(downloads, 0); +}); + +test("a race — present, then gone before the read — is transient-or-missing, never success", async () => { + const realSha = createHash("sha256").update(PNG).digest("hex"); + const raced = await verifyStoredCopy("p.png", realSha, undefined, SMALL, give({ ok: false, kind: "not-found" })); + assert.equal(raced.ok, false); + assert.equal((raced as { kind: string }).kind, "missing"); +}); + +test("ALL THREE replay paths ask this one rule, and answer a mismatch with 409", () => { + // Two copies of "do we hold it" is how one path came to be stricter than + // the other. The routes map the verdict to their own response shapes, but + // the verdict itself is decided here. + for (const [route, source] of Object.entries(replayRoutes)) { + assert.match(source, /verifyStoredCopy\(/, `${route} uses the shared rule`); + assert.match(source, /error: "content-mismatch"/, `${route} has a mismatch answer`); + assert.match(source, /retryable: false/, `${route}: resending the same bytes changes nothing`); + // The mismatch branch must be decided BEFORE any success is returned. + // + // LAST occurrence of each, because /start now declares its response + // union up top and `alreadyReceived: true` appears there as a TYPE + // before it appears as an answer. Each marker is returned from exactly + // one place, so the last mention IS the answer. + const mismatchAt = source.lastIndexOf('error: "content-mismatch"'); + const receivedAt = source.lastIndexOf("alreadyReceived: true"); + const finalizedAt = source.lastIndexOf("alreadyFinalized: true"); + assert.ok( + (receivedAt > 0 && mismatchAt < receivedAt) + || (finalizedAt > 0 && mismatchAt < finalizedAt), + `${route}: the mismatch is answered before the success`, + ); + // And never healed: a re-upload is exactly how bytes get replaced. + const mismatch = source.slice(source.indexOf('error: "content-mismatch"')); + assert.ok( + !/storeObject|uploadReceiptObject/.test(mismatch.slice(0, 600)), + `${route}: a mismatch must not overwrite the stored object`, + ); + } +}); + +test("a legacy row with no recorded sha is passed through, not refused", async () => { + // Rows written before sealing existed have nothing to compare against. + // Refusing them would park real receipts for a reason that is our fault. + const legacy = await downloadVerified("p.png", "", undefined, give({ ok: true, bytes: PNG })); + assert.deepEqual(legacy, { ok: true, bytes: PNG }); +}); + +test("the validator hands back the exact bytes it verified", async () => { + // The sealer copies THESE bytes rather than re-downloading, so the sealed + // object is provably the content that passed validation. + const check = await inspect("p.png", "image/png", give({ ok: true, bytes: PNG })); + assert.ok(check.ok); + assert.ok(check.bytes.equals(PNG)); + assert.equal(createHash("sha256").update(check.bytes).digest("hex"), check.fileSha256); +}); + +// ── Seal order: copy, COMMIT, then delete (round-9 items 1 and 3) ────────── + +const CHECK = { + mimeType: "image/png", + fileSize: PNG.length, + fileSha256: createHash("sha256").update(PNG).digest("hex"), + bytes: PNG, +}; + +/** + * A pass-through object lock, for the tests whose subject is the fenced CAS + * rather than the mutual exclusion. The real one is exercised above. + */ +/** A short transaction that just runs its body. No lock: there is none any more. */ +const noLock = (body: (tx: never) => Promise) => body(null as never); + +/** The path sealAndPublish("...", "row-1", 1, CHECK, ..., undefined) will compute. */ +const CANONICAL_ROW1 = `receipts/row-1/v1/${CHECK.fileSha256}.png`; + +/** + * `sealOk`/`committed` drive the outcome; the call log is always recorded. + * `currentPath` is only ever consulted when `committed` is 0 (a lost CAS): + * defaulting it to the row's OWN canonical path is the safe default — "the + * winner is using this exact object" — so a test that does not care about + * the orphan-cleanup branch never accidentally exercises it. + */ +function publishHarness( + opts: { + sealOk?: boolean; + committed?: number; + txThrows?: boolean; + } = {}, +) { + const calls: string[] = []; + const deps = { + // `tx-open`/`tx-close` bracket the SETTLE transaction in the call + // log, so every ordering assertion below also states which steps are + // inside it. The commit, the upload-cleanup enqueue and the intent's + // cancellation must be; THE SEAL MUST NOT — that is the whole finding. + inShortTx: async (body: (tx: never) => Promise) => { + calls.push("tx-open"); + if (opts.txThrows) throw new Error("could not open the transaction"); + try { + return await body(null as never); + } finally { + calls.push("tx-close"); + } + }, + seal: async (_u: string, canonical: string) => { + calls.push("seal"); + return opts.sealOk === false ? null : canonical; + }, + commit: async () => { calls.push("commit"); return opts.committed ?? 1; }, + claimCanonicalPath: async () => { calls.push("claim"); return "intent-1"; }, + resolveCanonicalIntent: async () => { calls.push("resolve-intent"); }, + queueUploadCleanup: async () => { calls.push("queue-cleanup"); return "ev-1"; }, + settleUploadCleanup: async () => { calls.push("drop"); }, + } as never; + return { calls, deps }; +} + +test("the upload object is deleted only AFTER the row pointer is committed", async () => { + // Deleting first is unrecoverable: if the UPDATE then fails, the row still + // points at a path whose object we just removed, and the receipt is gone + // with nothing left to retry from. + const h = publishHarness(); + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, h.deps, undefined); + // And the seal and the commit are INSIDE one critical section: the gap + // between them is exactly where the cleanup sweep used to fit. + // + // THE QUEUE ENTRY IS INSIDE IT TOO, between the commit and the unlock. It + // is the only thing that remembers the upload object once the pointer has + // moved, so it commits with that pointer or not at all; the actual delete + // stays outside, after the unlock, where a failure costs nothing because + // the sweep will pick the entry up. + // THE INTENT IS FIRST, and outside the lock: the canonical object is + // promised to the cleanup queue in its own committed transaction BEFORE + // the external write that creates it. Its cancellation is inside, with the + // pointer commit, so the two facts land together or neither does. + assert.deepEqual( + h.calls, + ["claim", "seal", "tx-open", "commit", "queue-cleanup", "resolve-intent", "tx-close", "drop"], + ); + // THE SEAL IS OUTSIDE THE TRANSACTION. That is the finding, stated as an + // ordering: `claim` and `seal` both precede `tx-open`, so the external + // write holds no connection, and the cancellation of the claim sits inside + // the same transaction as the pointer so the two land together. + assert.ok(h.calls.indexOf("seal") < h.calls.indexOf("tx-open"), "seal before any tx"); + assert.equal(outcome?.published, true); + assert.equal(outcome?.canonicalPath, `receipts/row-1/v1/${CHECK.fileSha256}.png`); +}); + +test("a LOST CAS touches neither the database nor storage", async () => { + // The lease was re-claimed, or another publisher moved the row first. + // + // The old code answered this by looking up what the winner was pointing at + // and DELETING from inside the transaction if it was pointing elsewhere. + // Both halves are gone: the lookup because it was a second round trip + // inside a held transaction, and the delete because a publisher that lost + // cannot safely decide anything about an object it no longer owns. + // + // The phase-A intent already accounts for the sealed copy. The sweeper + // resolves it once the lease lapses, re-checking live references first — + // the one place that question can be asked without racing a publish. + const h = publishHarness({ committed: 0 }); + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, h.deps, undefined); + + assert.deepEqual(h.calls, ["claim", "seal", "tx-open", "commit", "tx-close"]); + assert.ok(!h.calls.includes("resolve-intent"), "the intent survives to cover the sealed copy"); + assert.ok(!h.calls.includes("drop"), "and nothing is deleted by the loser"); + assert.equal(outcome?.published, false); + assert.equal(outcome?.canonicalPath, CANONICAL_ROW1); +}); + +test("a failed SEAL never touches the row or the upload object", async () => { + const h = publishHarness({ sealOk: false }); + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, h.deps, undefined); + assert.equal(outcome, null); + assert.deepEqual(h.calls, ["claim", "seal"], "no transaction was ever opened"); +}); + +test("a settle transaction that cannot open is a RETRYABLE null, never a verdict", async () => { + // The bytes ARE sealed by this point — that is unavoidable, the external + // write comes first — but nothing was committed, so the honest answer is + // the same "come back" a failed seal gives. The phase-A intent is what + // makes it safe: the sealed copy is accounted for, and the sweeper + // collects it if no retry ever claims it. + const h = publishHarness({ txThrows: true }); + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, h.deps, undefined); + assert.equal(outcome, null); + assert.deepEqual(h.calls, ["claim", "seal", "tx-open"]); + assert.ok(!h.calls.includes("resolve-intent"), "the intent survives to cover the sealed copy"); +}); + +test("a retry that finds the canonical object already there still commits", async () => { + // The crash-between-copy-and-commit case: the copy is an upsert to a + // content-addressed path, so re-sealing the same bytes is a no-op and the + // retry simply commits. + const h = publishHarness(); + const first = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, h.deps, undefined); + const second = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, h.deps, undefined); + assert.equal(first?.canonicalPath, second?.canonicalPath, "same content, same path"); + assert.equal(second?.published, true); +}); + +// ── Cleanup vs publication: the seal/commit gap (round-35 P0) ─────────────── + +/** + * THE SAME FAILURE, AGAINST THE LEASE PROTOCOL THAT REPLACED THE LOCK. + * + * A publish that has sealed its bytes but not yet committed its row pointer is + * the window in which nothing references the canonical path — so a cleanup + * sweep arriving in it used to delete the object the publish was about to + * point at, and the intake reported success while the row pointed at nothing. + * + * The advisory lock closed that window by holding a transaction (and a pooled + * connection) across the seal. What closes it now is the PROVISIONAL LEASE the + * publish records in phase A: the sweep reads the newest schedule for the path + * and skips a path whose lease has not lapsed. Same exclusion, no connection. + * + * `leased: false` is the CONTROL, and it is the point of the pair — a + * concurrency test whose "fixed" case passes proves nothing unless the + * unguarded case actually reproduces the loss. + */ +async function raceSweepAgainstPublish(leased: boolean) { + const UPLOAD = "receipts/intake/a.png"; + const objects = new Set([UPLOAD]); + let rowStoragePath = UPLOAD; + let sealDone!: () => void; + const sealed = new Promise(resolve => { sealDone = resolve; }); + // The queue, as the sweep sees it: phase A writes a provisional entry for + // the canonical path carrying a lease that has not yet lapsed. + const leaseUntil = new Map(); + + const publishing = sealAndPublish(UPLOAD, "row-1", 1, CHECK, { + inShortTx: (body: (tx: never) => Promise) => body(null as never), + claimCanonicalPath: async (canonicalPath: string) => { + if (leased) leaseUntil.set(canonicalPath, Date.now() + 60_000); + return "intent-1"; + }, + seal: async (_u: string, canonical: string) => { + objects.add(canonical); + sealDone(); + return canonical; + }, + commit: async (_tx: never, canonical: string) => { + // THE GAP. Wide here so the sweep is guaranteed to arrive inside + // it; in production it is a Supabase round trip. + await new Promise(resolve => setTimeout(resolve, 20)); + rowStoragePath = canonical; + return 1; + }, + resolveCanonicalIntent: async () => { leaseUntil.delete(CANONICAL_ROW1); }, + queueUploadCleanup: async () => "ev-1", + settleUploadCleanup: async (_id: string, uploadPath: string) => { objects.delete(uploadPath); }, + } as never, undefined); + + // The sweep, exactly as retryPendingCleanups decides: is a live row + // pointing at this path, and is the NEWEST schedule for it still ahead of + // us? Both questions are asked in a short transaction; neither is a lock. + await sealed; + const sweep = async () => { + if (rowStoragePath === CANONICAL_ROW1) return "referenced"; + const until = leaseUntil.get(CANONICAL_ROW1); + if (until && until > Date.now()) return "not-due"; + objects.delete(CANONICAL_ROW1); + return "deleted"; + }; + const swept = await sweep(); + + const outcome = await publishing; + return { outcome, swept, objects, rowStoragePath }; +} + +test("a cleanup that starts between the seal and the commit DEFERS, and the bytes survive", async () => { + const race = await raceSweepAgainstPublish(true); + assert.equal(race.outcome?.published, true); + assert.equal(race.swept, "not-due", "the publish's live lease held the path"); + assert.equal(race.rowStoragePath, CANONICAL_ROW1); + assert.ok(race.objects.has(CANONICAL_ROW1), "the row the sweep let through still has its bytes"); +}); + +test("CONTROL: without the lease the same interleaving publishes a row pointing at nothing", async () => { + // If this ever stops failing the way it does, the test above has stopped + // proving anything. + const race = await raceSweepAgainstPublish(false); + assert.equal(race.outcome?.published, true, "the intake reports success either way"); + assert.equal(race.swept, "deleted", "the sweep saw an unreferenced, unleased path"); + assert.equal(race.rowStoragePath, CANONICAL_ROW1); + assert.ok(!race.objects.has(CANONICAL_ROW1), "...and the successful intake now points at missing bytes"); +}); + +test("NO STORAGE CALL RUNS WITH A TRANSACTION OPEN", async () => { + // The finding, measured: the seal is the slow external call, and the + // publish must hold zero database transactions while it runs. The fake + // counts transactions open at the moment the seal is invoked. + let txOpen = 0; + let txOpenDuringSeal = -1; + let sealMs = 0; + + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, { + inShortTx: async (body: (tx: never) => Promise) => { + txOpen++; + try { return await body(null as never); } finally { txOpen--; } + }, + claimCanonicalPath: async () => "intent-1", + seal: async (_u: string, canonical: string) => { + txOpenDuringSeal = txOpen; + const started = Date.now(); + // A SLOW storage call — the fifteen-second class the round-16 + // deadline permits, compressed so the suite stays quick. + await new Promise(resolve => setTimeout(resolve, 60)); + sealMs = Date.now() - started; + return canonical; + }, + commit: async () => 1, + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => "ev-1", + settleUploadCleanup: async () => {}, + } as never, undefined); + + assert.equal(outcome?.published, true); + assert.ok(sealMs >= 50, `the seal really was slow (${sealMs}ms)`); + assert.equal(txOpenDuringSeal, 0, "ZERO transactions were open while storage was called"); +}); + +test("text/plain is no longer accepted at all", async () => { + // QuickBooks cannot attach a .txt, so accepting one meant reading it and + // then stranding it unbookable — worse than refusing at the door. + const txt = Buffer.from("VENDOR: Lowes\nTOTAL: 10.00"); + const check = await inspect("p.txt", "text/plain", give({ ok: true, bytes: txt })); + assert.equal(check.ok, false); + assert.equal((check as { reason: string }).reason, "unsupported-file-type"); +}); + +// ── A sha-mismatch is recoverable while the URL can still land (item 5) ──── + +test("the sweeper's two parks are the ones /finalize recovers from", () => { + // A partial upload sitting at the path while the signed URL is still valid + // is a retry in progress, not an error state. Parking it would turn the + // client's own next request into a review item — and the correct bytes + // arriving a minute later would find the row already out of STAGING. + // node:fs and node:path are imported at the top of this file now. + const root = path.resolve(__dirname, ".."); + + const sweeper = readFileSync( + path.join(root, "src/app/api/cron/receipt-intake-worker/route.ts"), "utf8", + ); + // Both the missing-object and the sha-mismatch branches wait for the + // UPLOAD LEASE to expire — the promise /start actually made, not the row's + // age, which is older than the lease on any re-issued URL. + const shaBranch = sweeper.slice(sweeper.indexOf('row.expectedSha256 !== check.fileSha256')); + assert.match( + shaBranch.slice(0, shaBranch.indexOf("parked++")), + /if \(leaseLive\) \{ leaseActive\+\+; continue; \}/, + "a sha mismatch waits for the upload lease to expire", + ); + assert.match(sweeper, /const leaseLive = uploadLeaseActive\(row\);/); + + const finalize = readFileSync( + path.join(root, "src/app/api/receipts/intake/[id]/finalize/route.ts"), "utf8", + ); + // Both sweeper parks are the ones /finalize may recover from, and it asks + // the shared rule rather than carrying its own copy of the list. + assert.match(finalize, /finalizeDisposition\(row\)/); + assert.deepEqual(RECOVERABLE_PARK_REASONS, ["file-missing", "sha-mismatch"]); +}); + +// ── Which parks a re-upload may clear, and the fence it publishes under ───── + +test("only the two SWEEPER parks are recoverable; a human's park is not", () => { + assert.equal(finalizeDisposition({ state: "STAGING", stateReason: null }), "publish"); + for (const reason of RECOVERABLE_PARK_REASONS) { + assert.equal(finalizeDisposition({ state: "NEEDS_REVIEW", stateReason: reason }), "publish", reason); + } + // Everything else parked for review is somebody's decision. Republishing it + // drags the row back to RECEIVED and re-reads it, discarding that decision. + for (const reason of ["vendor-mismatch", "weak-dup:row-9", "qbo-fault:6210", "amount-mismatch", null]) { + assert.equal( + finalizeDisposition({ state: "NEEDS_REVIEW", stateReason: reason }), + "not-recoverable", + String(reason), + ); + } + // And a row that already moved on is simply settled — not an error. + for (const state of ["RECEIVED", "READ", "BOOKING", "BOOKED", "ARCHIVED", "DUPLICATE"]) { + assert.equal(finalizeDisposition({ state, stateReason: null }), "settled", state); + } +}); + +/** An observed row, with a lease generation the fences can pin. */ +const NONCE = "nonce-a"; +const EXPIRY = new Date("2026-09-03T12:00:00.000Z"); +const observedRow = (over: Partial = {}): ObservedRow => ({ + state: "STAGING", + stateReason: null, + uploadLeaseVersion: 1, + uploadLeaseNonce: NONCE, + uploadUrlExpiresAt: EXPIRY, + ...over, +}); + +test("the publish fence pins the exact state, the exact reason and an unclaimed row", () => { + assert.deepEqual(publishFence(observedRow({ state: "NEEDS_REVIEW", stateReason: "file-missing" })), { + state: "NEEDS_REVIEW", + stateReason: "file-missing", + claimToken: null, uploadLeaseVersion: 1, + }); + assert.deepEqual(publishFence(observedRow()), { + state: "STAGING", + stateReason: null, + claimToken: null, uploadLeaseVersion: 1, + }); +}); + +test("the LEASE fence adds the generation publishFence cannot see", () => { + // The gap this closes: reuseLiveLease reissues a working signed URL over + // the SAME path at the SAME version and moves nothing else, so a finalizer + // that read the row before the refresh still satisfies publishFence. + assert.deepEqual(leaseFence(observedRow()), { + state: "STAGING", + stateReason: null, + claimToken: null, + uploadLeaseVersion: 1, + uploadLeaseNonce: NONCE, + uploadUrlExpiresAt: EXPIRY, + }); + // CONTROL: publishFence carries neither, which is exactly why a refresh + // was invisible to it — the two fences must differ on precisely these two + // keys and nothing else. + const weak = publishFence(observedRow()); + const strong = leaseFence(observedRow()); + assert.deepEqual( + Object.keys(strong).filter(k => !(k in weak)).sort(), + ["uploadLeaseNonce", "uploadUrlExpiresAt"], + ); +}); + +/** Enough of Prisma's updateMany semantics to run a CAS against one row. */ +function rowStore(row: Record) { + const store = { ...row }; + return { + get: () => store, + set: (patch: Record) => Object.assign(store, patch), + updateMany: (where: Record, data: Record) => { + const matches = Object.entries(where).every(([k, v]) => store[k] === v); + if (!matches) return 0; + Object.assign(store, data); + return 1; + }, + }; +} + +test("RACE: a reason that changes during sealing loses the publish, and writes nothing", async () => { + // The window is real: inspecting the object and sealing it takes seconds, + // and the worker can re-park the row in that time. Fenced only on the state + // SET (`state: { in: ["STAGING", "NEEDS_REVIEW"] }`) the stale finalizer + // would reset a reason it never looked at back to RECEIVED — discarding the + // newer decision and republishing a row somebody else now owns. + const store = rowStore({ + id: "row-1", state: "NEEDS_REVIEW", stateReason: "file-missing", claimToken: null, uploadLeaseVersion: 1, uploadLeaseNonce: NONCE, uploadUrlExpiresAt: EXPIRY, + }); + const observed = observedRow({ + state: store.get().state as string, + stateReason: store.get().stateReason as string, + uploadLeaseVersion: store.get().uploadLeaseVersion as number, + }); + assert.equal(finalizeDisposition(observed), "publish", "it was recoverable when we read it"); + const fence = leaseFence(observed); + + let dropped = false; + let droppedOrphan = false; + let resolvedIntent = false; + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => { + // THE RACE, in the exact window it happens: the worker re-parks the + // row while we are copying the bytes. + store.set({ stateReason: "vendor-mismatch" }); + return canonical; + }, + commit: async (_tx: never, canonicalPath: string) => + store.updateMany( + { id: "row-1", ...fence }, + { state: "RECEIVED", stateReason: null, storagePath: canonicalPath }, + ), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => { resolvedIntent = true; }, + queueUploadCleanup: async () => "ev-1", + settleUploadCleanup: async () => { dropped = true; }, + } as never, undefined); + + assert.equal(outcome?.published, false, "zero rows updated"); + assert.equal(store.get().state, "NEEDS_REVIEW", "the row is untouched"); + assert.equal(store.get().stateReason, "vendor-mismatch", "the newer decision survives"); + assert.equal(dropped, false, "and the upload object is kept for the retry"); + // Nobody's row points at the copy this call sealed — the row never + // advanced past NEEDS_REVIEW — so it IS an orphan. It is no longer + // deleted HERE, though: a publisher that lost its CAS cannot safely decide + // anything about an object it no longer owns, and the delete it used to do + // ran inside a held transaction. The phase-A intent covers it instead, and + // the sweeper collects it once the lease lapses, re-checking live + // references first. + assert.equal(droppedOrphan, false, "the loser deletes nothing; the intent accounts for it"); + assert.ok(!resolvedIntent, "and the intent is left standing to do that"); +}); + +test("RACE: a worker claim taken during sealing also loses the publish", async () => { + const store = rowStore({ + id: "row-1", state: "STAGING", stateReason: null, claimToken: null, uploadLeaseVersion: 1, uploadLeaseNonce: NONCE, uploadUrlExpiresAt: EXPIRY, + }); + const fence = leaseFence(observedRow()); + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => { + store.set({ claimToken: "sweeper-1" }); + return canonical; + }, + commit: async () => store.updateMany({ id: "row-1", ...fence }, { state: "RECEIVED" }), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => "ev-1", + settleUploadCleanup: async () => {}, + } as never, undefined); + assert.equal(outcome?.published, false); + assert.equal(store.get().state, "STAGING", "the sweeper's row is left alone"); +}); + +test("an unchanged row still publishes — the control", async () => { + const store = rowStore({ + id: "row-1", state: "NEEDS_REVIEW", stateReason: "sha-mismatch", claimToken: null, uploadLeaseVersion: 1, uploadLeaseNonce: NONCE, uploadUrlExpiresAt: EXPIRY, + }); + const fence = leaseFence(observedRow({ state: "NEEDS_REVIEW", stateReason: "sha-mismatch" })); + const outcome = await sealAndPublish("receipts/intake/a.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => canonical, + commit: async () => store.updateMany({ id: "row-1", ...fence }, { state: "RECEIVED", stateReason: null, uploadLeaseVersion: 1 }), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => "ev-1", + settleUploadCleanup: async () => {}, + } as never, undefined); + assert.equal(outcome?.published, true); + assert.equal(store.get().state, "RECEIVED"); +}); + +test("both publishers use the shared fence, and finalize refuses the other parks", () => { + const finalize = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + const intake = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/route.ts"), + "utf8", + ); + // leaseFence, not publishFence: BOTH publishers pin the lease generation + // too, so a /start that reissued the client's URL over the same path and + // version invalidates an in-flight finalizer instead of being invisible. + assert.match(finalize, /where: \{ id, \.\.\.leaseFence\(leased\), \.\.\.merged\.guard \}/); + assert.match(intake, /where: \{ id: existing\.id, \.\.\.leaseFence\(existing\) \}/); + for (const [name, src] of [["finalize", finalize], ["intake", intake]] as const) { + assert.ok(!/\bpublishFence\(/.test(src), `${name}: the weaker fence is gone entirely`); + } + assert.ok( + !/state: \{ in: \["STAGING", "NEEDS_REVIEW"\] \}/.test(finalize), + "the state-SET fence is gone", + ); + assert.match(finalize, /error: "not-recoverable"/); + assert.match(finalize, /disposition === "not-recoverable"/); +}); + +// ── Presence is TAGGED: 404 and "storage is unhappy" are different answers ── + +test("the size lookup separates a real absence from a storage fault", async () => { + // The bug this closes: a helper that collapsed both into `false`. The intake + // replay path reads it, and on a false it RE-UPLOADS and re-points the row — + // so a transient fault orphaned the object that was really there and left + // the row pointing at a second copy. + const lister = (result: unknown) => ({ list: async () => result as never }); + + const missing = await receiptObjectSize("receipts/intake/a.png", lister({ data: [], error: null }), undefined); + assert.deepEqual(missing, { ok: false, kind: "missing" }, "an empty listing IS an answer"); + + const notFound = await receiptObjectSize( + "receipts/intake/a.png", + lister({ data: null, error: { status: 404, message: "Object not found" } }), + undefined, + ); + assert.equal((notFound as { kind: string }).kind, "missing"); + + for (const error of [ + { status: 500, message: "boom" }, + { status: 401, message: "invalid jwt" }, + { status: 429, message: "slow down" }, + { message: "fetch failed" }, + ]) { + const fault = await receiptObjectSize("receipts/intake/a.png", lister({ data: null, error }), undefined); + assert.equal((fault as { kind: string }).kind, "transient", JSON.stringify(error)); + } + + const found = await receiptObjectSize( + "receipts/intake/a.png", + lister({ data: [{ name: "a.png", metadata: { size: 1234 } }], error: null }), + undefined, + ); + assert.deepEqual(found, { ok: true, size: 1234 }); + + // Present but sizeless is the one genuinely unknown case, and it must not + // become permission to download. + const sizeless = await receiptObjectSize( + "receipts/intake/a.png", + lister({ data: [{ name: "a.png", metadata: {} }], error: null }), + undefined, + ); + assert.equal((sizeless as { kind: string }).kind, "transient"); + + // A throwing client is a transport fault, never evidence of absence. + const threw = await receiptObjectSize("receipts/intake/a.png", { + list: async () => { throw new TypeError("fetch failed"); }, + }, undefined); + assert.equal((threw as { kind: string }).kind, "transient"); +}); + +test("the replay path heals only on an AFFIRMATIVE absence, and 503s on a fault", () => { + const intake = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/route.ts"), + "utf8", + ); + const branch = intake.slice(intake.indexOf("const held = await verifyStoredCopy(")); + const head = branch.slice(0, branch.indexOf("const healable")); + assert.match(head, /held\.kind === "transient"/); + assert.match(head, /status: 503/); + // The transient answer is handled BEFORE the not-ok branch that heals, so a + // storage fault can never reach storeObject. A CONTENT mismatch is answered + // ahead of it too: a re-upload is exactly how bytes get replaced, so healing + // one would let a replay launder the swap. + assert.ok( + head.indexOf('held.kind === "transient"') < head.indexOf("if (!held.ok) {"), + "the fault check comes first", + ); + assert.ok( + head.indexOf('held.kind === "content-mismatch"') < head.indexOf("if (!held.ok) {"), + "and so does the content check", + ); + assert.ok(!/storeObject/.test(head), "nothing is written on the fault or mismatch paths"); + // And the collapsing helper is gone, so nothing can reintroduce it. + const storage = readFileSync(path.join(__dirname, "..", "src/lib/secure-storage.ts"), "utf8"); + assert.ok(!/secureObjectExists/.test(storage), "no boolean exists-check to reach for"); +}); + +// ── /start's "we already have it" was PRESENCE, not verification ─────────── + +test("/start's settled branch verifies the BYTES, and no longer probes for a size", async () => { + // The finding: this branch checked object size/presence and returned + // `alreadyReceived`. The forwarder deletes its only copy on that answer, so + // an object replaced or corrupted after publication (the upload URL is + // `upsert: true`, a restore can put back a different version, storage can + // fault) was laundered into "we hold your receipt" and the last good copy + // went with it. Inline replay and /finalize already downloaded and + // SHA-verified; this path did not. + const start = replayRoutes["POST /api/receipts/intake/start"]; + const branch = start.slice(start.indexOf(`if (existing.state !== "STAGING") {`)); + const body = branch.slice(0, branch.indexOf("alreadyReceived: true")); + + // WITH THE REQUEST'S DEADLINE. This probe and the download behind it used + // to be issued with none at all -- a fresh fifteen seconds each, inside a + // handler the platform kills at thirty. + assert.match(body, /verifyStoredCopy\(existing\.storagePath, existing\.fileSha256, deadline\)/); + assert.ok( + !/receiptObjectSize/.test(start), + "the presence-only probe is gone from the route entirely, not merely bypassed", + ); + + // The three verdicts, each mapped to its own answer, all decided BEFORE any + // success is returned. + assert.ok(body.indexOf(`held.kind === "transient"`) < body.indexOf(`if (!held.ok) {`), + "a storage fault is answered first — it is never evidence about the bytes"); + assert.match(body, /reason: "verify-unavailable", retryable: true \}/); + assert.match(body, /status: 503/); + assert.ok(body.indexOf(`held.kind === "content-mismatch"`) < body.indexOf(`if (!held.ok) {`), + "and so is a content mismatch"); + assert.match(body, /error: "content-mismatch"/); + assert.match(body, /retryable: false/); + assert.match(body, /error: "file-missing"/, "an affirmative absence keeps its own 409"); + assert.ok(!/storeObject|uploadReceiptObject|updateMany/.test(body), + "and none of the three heals or otherwise writes to the row"); +}); + +test("the three verdicts /start maps: mutated -> mismatch, fault -> transient, match -> ok", async () => { + // The behaviour behind the mapping above, driven through the same shared + // rule the route calls, with storage injected. + const realSha = createHash("sha256").update(PNG).digest("hex"); + const mutated = Buffer.concat([PNG, Buffer.from([0])]); + + // A replaced object: 409 content-mismatch, and the row is left alone. + const swapped = await verifyStoredCopy("p.png", realSha, undefined, SMALL, give({ ok: true, bytes: mutated })); + assert.equal(swapped.ok, false); + assert.equal((swapped as { kind: string }).kind, "content-mismatch"); + + // A download that FAILS (downloadReceiptObject turns a thrown transport + // fault into exactly this): 503 verify-unavailable, retryable. Never a + // verdict about the bytes, so never the file-missing answer. + const faulted = await verifyStoredCopy( + "p.png", realSha, undefined, SMALL, give({ ok: false, kind: "transient", message: "TypeError: fetch failed" }), + ); + assert.equal((faulted as { kind: string }).kind, "transient"); + + // Only verified bytes reach alreadyReceived. + assert.deepEqual(await verifyStoredCopy("p.png", realSha, undefined, SMALL, give({ ok: true, bytes: PNG })), { ok: true }); +}); + +// -- A DECLARED hash is answered on EVERY success path (round-34 item 3) ----- + +const SHA_A = "a".repeat(64); +const SHA_B = "b".repeat(64); + +test("a declared hash that disagrees with the row's verified one is a conflict", () => { + assert.equal(declaredShaConflict(SHA_A, SHA_B), true); + // Case is not identity: /start lowercases what it stores, a forwarder may + // not, and rejecting on case alone would break honest callers. + assert.equal(declaredShaConflict(SHA_A.toUpperCase(), SHA_A), false); + assert.equal(declaredShaConflict(SHA_A, SHA_A.toUpperCase()), false); + assert.equal(declaredShaConflict(SHA_A, SHA_A), false); +}); + +test("silence is not a conflict: no declared hash, and no verified one", () => { + // The caller asserted nothing. + assert.equal(declaredShaConflict(SHA_A, null), false); + assert.equal(declaredShaConflict(SHA_A, ""), false); + // A STAGING row (fileSha256 is "") or a legacy row written before sealing + // existed has no verified identity to compare against — the publish path + // checks the declared hash against the BYTES instead, which is stronger. + assert.equal(declaredShaConflict("", SHA_B), false); + assert.equal(declaredShaConflict(null, SHA_B), false); +}); + +test("/finalize enforces it ABOVE the disposition split, so no success bypasses it", () => { + // The hole: `declaredSha` was compared to the STORED BYTES on the publish + // path only. A finalize against an already-settled row (RECEIVED, READ, + // BOOKED) verified storage against the ROW's hash, never looked at the hash + // the REQUEST carried, and returned 200 alreadyFinalized — so a forwarder + // with a stale or wrong row id was told we held ITS receipt while we held a + // different one, and it deletes its only copy on that answer. + const finalize = replayRoutes["POST /api/receipts/intake/{id}/finalize"]; + const guardAt = finalize.indexOf("declaredShaConflict(row.fileSha256, declaredSha)"); + assert.notEqual(guardAt, -1, "the guard must be wired to the row's verified hash"); + assert.ok(guardAt < finalize.indexOf("const disposition = finalizeDisposition(row)"), + "above the disposition split"); + assert.ok(guardAt < finalize.indexOf("alreadyFinalized: true"), "above every success response"); + assert.ok(guardAt < finalize.indexOf("await applyLateFields("), "and above every write"); + // A 409, never a 2xx and never a write. + const guard = finalize.slice(guardAt, finalize.indexOf("// Authorize the late fields")); + assert.match(guard, /error: "sha-mismatch"/); + assert.match(guard, /status: 409/); + assert.ok(!/prisma\.receiptIntake\.update/.test(guard), "nothing is written on the way out"); +}); + +test("the OTHER two replay paths already refuse a mismatching hash", () => { + // Same rule, and it has to hold in all three or a forwarder can pick the + // endpoint that answers most generously. + // + // /start requires `sha256` and compares it to whatever the row has recorded + // BEFORE it can reach the settled "alreadyReceived" answer; the inline POST + // hashes the bytes in the request body and compares those. Neither needs the + // helper above — they already fail closed — but if either check is ever + // removed this says so. + assert.match( + replayRoutes["POST /api/receipts/intake/start"], + /if \(!knownSha \|\| knownSha !== expectedSha256\) \{/, + ); + assert.match( + replayRoutes["POST /api/receipts/intake"], + /if \(existing\.fileSha256 !== fileSha256\) \{/, + ); +}); + +// ── A /start REFRESH during an in-flight finalize (Codex round-12 item 1) ─── +// +// `reuseLiveLease` hands a retrying client a brand-new signed URL over the +// SAME path at the SAME lease version. It writes exactly two columns — +// `uploadLeaseNonce` and `uploadUrlExpiresAt` — and moves nothing else. So a +// fence built from state/reason/claim/version matched just as well after that +// refresh as before it, and a finalizer that had already read the row could: +// +// - PUBLISH bytes the client has just been invited to replace, and schedule +// the upload object's cleanup against the expiry it read — which the +// refreshed URL then outlives, so a later valid PUT recreates an object no +// row references and no sweep is looking for; or +// - REJECT the row, deleting it out from under a client whose upload link +// works again, on that same stale schedule. +// +// The fix is `leaseFence`. Each test below runs the interleaving, and each +// carries the pre-fix control: the same interleaving judged by `publishFence`, +// which still matches and would still have written. + +/** What reuseLiveLease writes: a fresh generation and a longer window. */ +const REFRESHED_NONCE = "nonce-b"; +const REFRESHED_EXPIRY = new Date(EXPIRY.getTime() + 2 * 60 * 60_000); + +function leasedRow() { + return rowStore({ + id: "row-1", + state: "STAGING", + stateReason: null, + claimToken: null, + storagePath: "receipts/intake/row-1.v1.png", + uploadLeaseVersion: 1, + uploadLeaseNonce: NONCE, + uploadUrlExpiresAt: EXPIRY, + }); +} + +/** The /start retry that adopts the live lease. Same path, same version. */ +function refreshLease(store: ReturnType) { + store.set({ uploadLeaseNonce: REFRESHED_NONCE, uploadUrlExpiresAt: REFRESHED_EXPIRY }); +} + +test("PUBLISH vs REFRESH: a lease reissued mid-finalize invalidates the publish", async () => { + const store = leasedRow(); + // The finalizer reads the row... + const observed = observedRow({ + state: store.get().state as string, + stateReason: store.get().stateReason as string | null, + uploadLeaseVersion: store.get().uploadLeaseVersion as number, + uploadLeaseNonce: store.get().uploadLeaseNonce as string, + uploadUrlExpiresAt: store.get().uploadUrlExpiresAt as Date, + }); + + let queued = 0; + const outcome = await sealAndPublish("receipts/intake/row-1.v1.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => { + // ...and /start refreshes the lease while the bytes are being sealed. + refreshLease(store); + return canonical; + }, + commit: async () => store.updateMany( + { id: "row-1", ...leaseFence(observed) }, + { state: "RECEIVED", stateReason: null }, + ), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => { queued++; return "ev-1"; }, + settleUploadCleanup: async () => {}, + } as never, undefined); + + assert.equal(outcome?.published, false, "the publish lost"); + assert.equal(store.get().state, "STAGING", "the row is untouched, still awaiting its upload"); + assert.equal(queued, 0, "and nothing was queued against the stale expiry"); + + // THE PRE-FIX CONTROL. The same interleaving, judged by publishFence: it + // matches, so the old code published — over a lease somebody else now owns, + // and scheduled the upload cleanup against an expiry two hours too early. + const wouldHaveMatched = Object.entries(publishFence(observed)) + .every(([k, v]) => store.get()[k] === v); + assert.equal(wouldHaveMatched, true, "publishFence cannot see a refresh — that was the bug"); +}); + +test("PUBLISH vs REFRESH control: an UNrefreshed lease still publishes", async () => { + // Without this, a leaseFence that simply never matched would pass the test + // above while breaking every honest publish. + const store = leasedRow(); + const observed = observedRow({ + state: "STAGING", + stateReason: null, + uploadLeaseVersion: 1, + uploadLeaseNonce: NONCE, + uploadUrlExpiresAt: EXPIRY, + }); + let queued = 0; + const outcome = await sealAndPublish("receipts/intake/row-1.v1.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => canonical, + commit: async () => store.updateMany( + { id: "row-1", ...leaseFence(observed) }, + { state: "RECEIVED", stateReason: null }, + ), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => { queued++; return "ev-1"; }, + settleUploadCleanup: async () => {}, + } as never, undefined); + assert.equal(outcome?.published, true); + assert.equal(store.get().state, "RECEIVED"); + assert.equal(queued, 1, "and the upload object's cleanup is queued in the commit"); +}); + +// ── THE SWEEPER IS THE THIRD PUBLISHER (Codex round-14 item 2) ────────────── +// +// /finalize and the stale-STAGING sweep both move a STAGING row, and a /start +// retry can extend its lease between either one's inspection and its write. +// The sweep computes `leaseLive` from the SELECT at the top of the pass and +// then spends a storage round trip per row, so that window is seconds wide. +// +// Its sha-mismatch park, its publish commit and its file-missing park all +// fenced on {state, version} — which a refresh does not move — so the sweep +// parked or published over a URL a client was still uploading to, and +// scheduled the upload path's cleanup against an obsolete expiry. Only the +// reject branch carried the whole identity, which is what proved the intent. + +/** What reuseLiveLease writes: same path, same version, new generation. */ +function refreshedInto(store: ReturnType) { + store.set({ + uploadLeaseNonce: "nonce-refreshed", + uploadUrlExpiresAt: new Date(EXPIRY.getTime() + 2 * 60 * 60_000), + }); +} + +/** The row the sweeper SELECTed, and the fence it must therefore carry. */ +function stagingRow() { + return rowStore({ + id: "row-1", + state: "STAGING", + stateReason: null, + claimToken: null, + storagePath: "receipts/intake/row-1.v1.png", + uploadLeaseVersion: 1, + uploadLeaseNonce: NONCE, + uploadUrlExpiresAt: EXPIRY, + }); +} + +const observedStaging = (store: ReturnType) => observedRow({ + state: store.get().state as string, + stateReason: store.get().stateReason as string | null, + uploadLeaseVersion: store.get().uploadLeaseVersion as number, + uploadLeaseNonce: store.get().uploadLeaseNonce as string, + uploadUrlExpiresAt: store.get().uploadUrlExpiresAt as Date, +}); + +test("SWEEP vs REFRESH: a park loses to a lease reissued mid-inspection", async () => { + const store = stagingRow(); + const observed = observedStaging(store); + + // The storage round trip the sweep makes per row — and the /start retry + // that lands inside it. + refreshedInto(store); + + // The park the sweep would then write, fenced exactly as the code does. + const parked = store.updateMany( + { id: "row-1", ...leaseFence(observed) }, + { state: "NEEDS_REVIEW", stateReason: "file-missing" }, + ); + + assert.equal(parked, 0, "the park matched nothing"); + assert.equal(store.get().state, "STAGING", "the client's row survives"); + assert.equal(store.get().uploadLeaseNonce, "nonce-refreshed", "and its new lease stands"); + + // PRE-FIX CONTROL: the fence the sweep used to carry still matches, because + // a refresh moves neither the state nor the version. + const halfFence = { id: "row-1", state: "STAGING", uploadLeaseVersion: 1 }; + const wouldHaveMatched = Object.entries(halfFence) + .every(([k, v]) => store.get()[k] === v); + assert.equal(wouldHaveMatched, true, "state + version cannot see a refresh — that was the bug"); +}); + +test("SWEEP vs REFRESH: the publish commit loses too", async () => { + // Publishing is allowed while a lease is live, so this branch is reachable + // with a working URL by design — which is exactly why its CAS has to see a + // refresh. Sealing bytes the client is about to replace, and then + // scheduling the upload path's cleanup against the OLD expiry, is the + // orphan the schedule exists to prevent. + const store = stagingRow(); + const observed = observedStaging(store); + + let queuedExpiry: Date | null = null; + const outcome = await sealAndPublish("receipts/intake/row-1.v1.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => { + refreshedInto(store); + return canonical; + }, + commit: async () => store.updateMany( + { id: "row-1", ...leaseFence(observed) }, + { state: "RECEIVED", stateReason: null }, + ), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => { + queuedExpiry = observed.uploadUrlExpiresAt; + return "ev-1"; + }, + settleUploadCleanup: async () => {}, + } as never, undefined); + + assert.equal(outcome?.published, false, "the sweep did not publish over the refreshed lease"); + assert.equal(store.get().state, "STAGING"); + assert.equal(queuedExpiry, null, "and queued no cleanup on the obsolete expiry"); +}); + +test("SWEEP CONTROL: an unrefreshed row still parks and still publishes", async () => { + // Without this, a fence that simply never matched would pass both tests + // above while stopping the sweep from doing anything at all. + const parkStore = stagingRow(); + assert.equal( + parkStore.updateMany( + { id: "row-1", ...leaseFence(observedStaging(parkStore)) }, + { state: "NEEDS_REVIEW", stateReason: "file-missing" }, + ), + 1, + ); + assert.equal(parkStore.get().state, "NEEDS_REVIEW"); + + const pubStore = stagingRow(); + const observed = observedStaging(pubStore); + const outcome = await sealAndPublish("receipts/intake/row-1.v1.png", "row-1", 1, CHECK, { + inShortTx: noLock, + seal: async (_u: string, canonical: string) => canonical, + commit: async () => pubStore.updateMany( + { id: "row-1", ...leaseFence(observed) }, + { state: "RECEIVED", stateReason: null }, + ), + claimCanonicalPath: async () => "intent-1", + resolveCanonicalIntent: async () => {}, + queueUploadCleanup: async () => "ev-1", + settleUploadCleanup: async () => {}, + } as never, undefined); + assert.equal(outcome?.published, true); + assert.equal(pubStore.get().state, "RECEIVED"); +}); + +test("THREE PUBLISHERS, one fence: /finalize, the sweeper and a /start refresh", () => { + // The interleaving as source: both publishers reach the same builder, and + // the sweep's parks now do too. A reader should not have to diff three + // where clauses to know they agree. + const finalize = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/[id]/finalize/route.ts"), + "utf8", + ); + const sweeper = readFileSync( + path.join(__dirname, "..", "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", + ); + const start = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/start/route.ts"), + "utf8", + ); + assert.match(finalize, /where: \{ id, \.\.\.leaseFence\(leased\), \.\.\.merged\.guard \}/); + // The sweep: sha-mismatch park, publish commit, file-missing park. + const sweep = sweeper.slice( + sweeper.indexOf("sweepStaleStaging: async"), + sweeper.indexOf("loadPhases:"), + ); + assert.equal((sweep.match(/\.\.\.leaseFence\(row\)/g) ?? []).length, 3); + // ...and the counters only move when the CAS did. + assert.match(sweep, /if \(mismatchParked > 0\) parked\+\+;/); + assert.match(sweep, /if \(missingParked > 0\) parked\+\+;/); + // /start builds its fence inside the shared repath helper, so neither of + // its two branches can hand in half an identity. + assert.match(start, /where: \{ id: existing\.id, \.\.\.leaseFence\(existing\) \},/); + assert.equal((start.match(/await repathWithCleanup\(/g) ?? []).length, 2); +}); diff --git a/tests/receipt-intake-upload-lease.test.ts b/tests/receipt-intake-upload-lease.test.ts new file mode 100644 index 000000000..6dcc6153b --- /dev/null +++ b/tests/receipt-intake-upload-lease.test.ts @@ -0,0 +1,796 @@ +/** + * The one lease-reuse rule /start applies, in every resumable state. + * + * The interesting behaviour here is entirely about a RACE — two /start calls for + * one sourceRef, which is the normal shape of a network retry, a double-tap, or + * a forwarder's own retry policy — so it lives in a lib and is driven through an + * injected client rather than by standing up a route handler. + * + * The failure it exists to prevent: every branch other than this one is + * destructive by design (new lease version, new path, the previous object + * deleted). Running that while a signed URL is still live invalidates the FIRST + * caller's URL and deletes the object it is about to PUT its bytes to. An + * earlier round fixed exactly this for STAGING rows and left the recoverable + * NEEDS_REVIEW re-arm alone — so the rule is now one rule, and both callers are + * exercised below. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + discardUnresumedLease, + extendedExpiry, + issuedLeaseIsCurrent, + liveLeasePath, + newLeaseNonce, + reuseLiveLease, + type LeaseRow, +} from "../src/lib/receipt-intake/upload-lease"; +import { uploadPathFor } from "../src/lib/receipt-intake/stored-object"; + +const HOUR = 60 * 60_000; + +/** The generation the CREATING request stamped on the lease. */ +const CREATED_NONCE = "lease-nonce-created"; + +type LeaseFixture = LeaseRow & { uploadLeaseNonce: string }; + +/** A recoverable park: the state the earlier fix did NOT cover. */ +const parked = (over: Partial = {}): LeaseFixture => ({ + id: "row-1", + state: "NEEDS_REVIEW", + stateReason: "sha-mismatch", + uploadLeaseVersion: 2, + storagePath: uploadPathFor("row-1", 2, "png"), + uploadUrlExpiresAt: new Date(Date.now() + HOUR), + uploadLeaseNonce: CREATED_NONCE, + ...over, +}); + +const staging = (over: Partial = {}): LeaseFixture => parked({ + state: "STAGING", + stateReason: null, + uploadLeaseVersion: 1, + storagePath: uploadPathFor("row-1", 1, "png"), + ...over, +}); + +interface Store { + rows: Record[]; + signed: string[]; + deleted: string[]; +} + +/** + * A store whose `updateMany` really evaluates the where clause, because the + * FENCE is the whole subject: a CAS that matched on the id alone would report + * success for a row somebody else had already moved. + */ +function client(rows: (LeaseRow | Record)[]) { + const store: Store = { rows: rows.map(r => ({ ...r } as Record)), signed: [], deleted: [] }; + let adoptions = 0; + let duringSign: () => Promise | void = () => {}; + const matching = (where: Record) => (row: Record) => + Object.entries(where).every(([key, want]) => { + const have = row[key]; + if (want instanceof Date) return have instanceof Date && have.getTime() === want.getTime(); + return have === want || (have == null && want == null); + }); + const deps = { + db: { + updateMany: async ({ where, data }: { where: Record; data: Record }) => { + const hits = store.rows.filter(matching(where)); + for (const hit of hits) Object.assign(hit, data); + return { count: hits.length }; + }, + // The discard is a DELETE over the same evaluated where, so the fake + // has to be able to lose the CAS the same way the database would. + deleteMany: async ({ where }: { where: Record }) => { + const hits = store.rows.filter(matching(where)); + store.rows = store.rows.filter(row => !hits.includes(row)); + for (const hit of hits) store.deleted.push(String(hit.storagePath)); + return { count: hits.length }; + }, + }, + // Re-reads the LIVE row out of the store, so a loser of the adoption + // CAS sees what actually won rather than a snapshot the test posed. + reload: async (id: string) => + (store.rows.find(r => r.id === id) as unknown as LeaseRow | undefined) ?? null, + sign: async (storagePath: string) => { + store.signed.push(storagePath); + // A hook for the tests that need somebody else to move the row + // WHILE the signing round trip is in flight. + await duringSign(); + return { uploadUrl: `https://example/${storagePath}`, token: "tok", storagePath }; + }, + expiresAt: () => new Date(Date.now() + 2 * HOUR), + // Deterministic, and never the creator's: an adoption must always be + // distinguishable from the lease it adopted. + nonce: () => "lease-nonce-adopted-" + (++adoptions), + }; + return { + store, + deps, + /** Run `body` inside the signing round trip, once. */ + onSign(body: () => Promise | void) { + let fired = false; + duringSign = async () => { + if (fired) return; + fired = true; + await body(); + }; + }, + }; +} + +// ── The finding: a recoverable row's retries were NOT idempotent ─────────── + +test("CONCURRENT RETRY on a recoverable row: same path, same lease version, no delete", async () => { + // Two /start calls arrive for one parked sourceRef. Before this, EVERY one + // of them bumped uploadLeaseVersion, repointed storagePath, and deleted the + // previous path — so the second call destroyed the object the first was + // about to upload to, and the first request's signed URL pointed at nothing. + const row = parked(); + const { store, deps } = client([row]); + + const [first, second] = await Promise.all([ + reuseLiveLease(row, "png", deps, { expectedSha256: "a".repeat(64), fileSha256: "" }), + reuseLiveLease(row, "png", deps, { expectedSha256: "a".repeat(64), fileSha256: "" }), + ]); + + for (const outcome of [first, second]) { + assert.ok(outcome, "a live lease is reused, never re-armed"); + assert.equal(outcome!.kind, "signed"); + } + assert.deepEqual(store.signed, [row.storagePath, row.storagePath], "the SAME path, twice"); + assert.equal(store.rows[0].uploadLeaseVersion, 2, "the version never moved"); + assert.equal(store.rows[0].storagePath, row.storagePath, "and neither did the path"); + assert.deepEqual(store.deleted, [], "nothing was deleted"); +}); + +test("the recovery's own state writes land; its ANNOUNCED HASH may not change", async () => { + // A recoverable park can legitimately come back with a CORRECTED hash (a + // re-scanned Drive file, a recomputed digest) -- but NOT while a lease is + // live. The announced hash is part of the live lease's identity: writing + // it through an extension kept the same generation, so two callers held + // ONE lease for two different documents and only whichever hash landed + // last could finalize. The correction lands on a NEW lease once this one + // lapses (see the control below). + const SHA = "b".repeat(64); + const row = parked({ storagePath: uploadPathFor("row-1", 2, "png"), expectedSha256: SHA }); + const { store, deps } = client([ + { ...row, fileSha256: "stale", nextRetryAt: new Date() }, + ]); + + // AGREEING on the hash: the recovery's own state writes still land. + const outcome = await reuseLiveLease(row, "png", deps, { + fileSha256: "", + nextRetryAt: null, + }, SHA); + assert.equal(outcome!.kind, "signed"); + assert.equal(store.rows[0].expectedSha256, SHA); + assert.equal(store.rows[0].fileSha256, ""); + assert.equal(store.rows[0].nextRetryAt, null); + assert.equal(store.rows[0].uploadLeaseVersion, 2, "still not a new lease"); +}); + +test("TWO CALLERS, TWO HASHES: the second is refused and the first still finalizes", async () => { + // The finding, exactly. The second caller used to overwrite + // expectedSha256 while keeping the generation, so the first caller's 200 + // -- same lease, same URL -- could no longer be finalized against the + // hash it had announced. + const FIRST = "a".repeat(64); + const SECOND = "b".repeat(64); + const row = parked({ storagePath: uploadPathFor("row-1", 2, "png"), expectedSha256: FIRST }); + const { store, deps } = client([row]); + + const held = await reuseLiveLease(row, "png", deps, { fileSha256: "" }, FIRST); + assert.equal(held!.kind, "signed", "the first caller holds the lease"); + + const other = await reuseLiveLease(row, "png", deps, { fileSha256: "" }, SECOND); + assert.equal(other!.kind, "identity-conflict"); + assert.equal((other as { field: string }).field, "sha256"); + // THE EXPIRY IT OBSERVED, which is what this caller read in this request. + // The winner has since extended it, so a client that waits exactly this + // long may be refused once more and wait again -- under-reporting the wait + // is safe, and re-reading the row purely to quote a longer one would be a + // round trip spent on a refusal. + assert.equal( + (other as { expiresAt: Date }).expiresAt.getTime(), + row.uploadUrlExpiresAt!.getTime(), + "the refusal says when the lease it lost to expires", + ); + assert.ok( + (store.rows[0].uploadUrlExpiresAt as Date).getTime() >= row.uploadUrlExpiresAt!.getTime(), + "and the live lease only ever runs longer than that, never shorter", + ); + + // THE PROPERTY: the first caller's announced hash is untouched, so the + // 200 it is holding is still finalizable. + assert.equal(store.rows[0].expectedSha256, FIRST); + assert.equal( + store.rows[0].uploadLeaseNonce, + (held as { signed: { uploadLease: string } }).signed.uploadLease, + ); + assert.deepEqual(store.signed, [row.storagePath], "and no URL was issued to the loser"); +}); + +test("PRE-FIX CONTROL: writing the hash through an extension strands the first", async () => { + // The old shape, reproduced: the identity rode along in `rearm`. + const FIRST = "a".repeat(64); + const SECOND = "b".repeat(64); + const row = parked({ storagePath: uploadPathFor("row-1", 2, "png"), expectedSha256: FIRST }); + const { store, deps } = client([row]); + + const held = await reuseLiveLease(row, "png", deps, { fileSha256: "" }, FIRST); + const firstLease = (held as { signed: { uploadLease: string } }).signed.uploadLease; + + // What the second caller used to do, straight to the store. + await deps.db.updateMany({ + where: { id: row.id }, + data: { expectedSha256: SECOND }, + }); + + assert.equal(store.rows[0].uploadLeaseNonce, firstLease, "same generation, still"); + assert.notEqual( + store.rows[0].expectedSha256, + FIRST, + "...but the hash the first caller announced is gone, so its upload cannot verify", + ); +}); + +test("a legacy lease that announced NO hash adopts this request's", async () => { + // Nothing to disagree with. Without this, the guard would refuse every + // retry against a row written before the field existed. + const row = parked({ storagePath: uploadPathFor("row-1", 2, "png"), expectedSha256: null }); + const { store, deps } = client([row]); + const outcome = await reuseLiveLease(row, "png", deps, {}, "c".repeat(64)); + assert.equal(outcome!.kind, "signed"); + assert.equal(store.rows[0].expectedSha256, "c".repeat(64)); +}); + +test("the lease EXPIRY moves with the URL it reissues", async () => { + // A resigned URL is good for a fresh window. Leaving the row's recorded + // expiry at its OLD value let the sweeper judge the lease dead and reclaim a + // row whose client was still holding a perfectly live URL. + const row = parked({ uploadUrlExpiresAt: new Date(Date.now() + 60_000) }); + const { store, deps } = client([row]); + await reuseLiveLease(row, "png", deps); + const after = store.rows[0].uploadUrlExpiresAt as Date; + assert.ok(after.getTime() > row.uploadUrlExpiresAt!.getTime(), "extended"); +}); + +// ── STAGING answers the same way, from the same code ────────────────────── + +test("a STAGING retry against a live lease is idempotent too", async () => { + const row = staging(); + const { store, deps } = client([row]); + const first = await reuseLiveLease(row, "png", deps); + const second = await reuseLiveLease(row, "png", deps); + assert.equal(first!.kind, "signed"); + assert.equal(second!.kind, "signed"); + assert.deepEqual(store.signed, [row.storagePath, row.storagePath]); + assert.equal(store.rows[0].uploadLeaseVersion, 1); +}); + +// ── When there is nothing live to reuse ─────────────────────────────────── + +test("an EXPIRED lease is not reused — the caller takes a new one", async () => { + // Fair game to invalidate: nothing live can still be relying on it. + for (const state of [parked, staging]) { + const row = state({ uploadUrlExpiresAt: new Date(Date.now() - 1) }); + const { store, deps } = client([row]); + assert.equal(await reuseLiveLease(row, "png", deps), null, row.state); + assert.deepEqual(store.signed, [], "and no URL was handed out here"); + } +}); + +test("a row that never had a lease is not reused", async () => { + // The single-shot POST path writes no uploadUrlExpiresAt at all. + const row = parked({ uploadUrlExpiresAt: null }); + const { deps } = client([row]); + assert.equal(await reuseLiveLease(row, "png", deps), null); +}); + +test("a CHANGED extension on a LIVE lease is REFUSED, never a repath", async () => { + // The path is derived from (id, leaseVersion, ext), so a caller that + // changed its declared type cannot reuse it -- and it used to fall + // through to the destructive resume, which bumped the version, repathed + // the row and rotated the generation WHILE THE PREVIOUS URL WAS STILL + // LIVE. The first caller was left holding a working signed URL for an + // object about to be orphaned, under a lease /finalize would refuse. + const row = parked(); + const { store, deps } = client([row]); + + const outcome = await reuseLiveLease(row, "pdf", deps, {}, "a".repeat(64)); + + assert.equal(outcome!.kind, "identity-conflict"); + assert.equal((outcome as { field: string }).field, "mime"); + assert.equal( + (outcome as { expiresAt: Date }).expiresAt.getTime(), + row.uploadUrlExpiresAt!.getTime(), + "and it says when the live lease expires, so the caller can wait", + ); + assert.deepEqual(store.signed, [], "no URL for a type this lease was not issued for"); + assert.equal(store.rows[0].storagePath, row.storagePath, "the row is untouched"); + assert.equal(store.rows[0].uploadLeaseVersion, 2, "and so is the lease"); + assert.equal(store.rows[0].uploadLeaseNonce, CREATED_NONCE, "and its generation"); +}); + +test("CONTROL: once the lease has EXPIRED, a changed extension takes a new one", async () => { + // The refusal above is about a LIVE lease. Nothing relies on a lapsed + // one, so the destructive branch is safe -- which is what makes the + // refusal a wait rather than a dead end. + const row = parked({ uploadUrlExpiresAt: new Date(Date.now() - 1) }); + const { store, deps } = client([row]); + assert.equal(await reuseLiveLease(row, "pdf", deps, {}, "a".repeat(64)), null); + assert.deepEqual(store.signed, []); + assert.equal(store.rows[0].storagePath, row.storagePath, "and this rule changed nothing"); +}); + +test("liveLeasePath answers the same three questions on its own", () => { + const row = parked(); + assert.equal(liveLeasePath(row, "png"), row.storagePath); + assert.equal(liveLeasePath(row, "pdf"), null, "wrong extension"); + assert.equal(liveLeasePath({ ...row, uploadUrlExpiresAt: null }, "png"), null, "no lease"); + assert.equal( + liveLeasePath(row, "png", row.uploadUrlExpiresAt!.getTime()), + null, + "an expiry exactly NOW is expired, not live", + ); +}); + +// ── A lost fence never falls through to the destructive branch ──────────── + +test("a row that MOVED under us is a conflict, never a repath-and-delete", async () => { + // We know a live lease existed a moment ago. Re-pathing and deleting on the + // strength of a row that just changed is precisely what this prevents — the + // client retries and reads whatever the winner left. + const row = parked(); + const { store, deps } = client([{ ...row, uploadLeaseVersion: 3 }]); + const outcome = await reuseLiveLease(row, "png", deps); + assert.deepEqual(outcome, { kind: "conflict" }); + assert.deepEqual(store.signed, [], "no URL for a lease we could not extend"); +}); + +test("a row the WORKER claimed loses the fence", async () => { + const row = parked(); + const { store, deps } = client([{ ...row, claimToken: "worker-1" }]); + assert.deepEqual(await reuseLiveLease(row, "png", deps), { kind: "conflict" }); + assert.deepEqual(store.signed, []); +}); + +test("a row RE-PARKED under a different reason loses the fence", async () => { + // file-missing -> vendor-mismatch is a human's decision. Quietly re-arming + // it would clear a hash and a retry time nobody here looked at. + const row = parked({ stateReason: "file-missing" }); + const { store, deps } = client([{ ...row, stateReason: "vendor-mismatch" }]); + assert.deepEqual(await reuseLiveLease(row, "png", deps), { kind: "conflict" }); + assert.deepEqual(store.signed, []); +}); + +test("the CAS carries the WHOLE lease identity, generation included", async () => { + const row = parked(); + const seen: Record[] = []; + const { deps } = client([row]); + const spy = { + ...deps, + db: { + updateMany: async (args: { where: Record; data: Record }) => { + seen.push(args.where); + return { count: 1 }; + }, + }, + }; + await reuseLiveLease(row, "png", spy); + // THE NONCE AND THE EXPIRY ARE IN THE WHERE CLAUSE. They used to be + // left out on purpose, so two concurrent adopters both matched, both + // wrote their own generation, and the earlier one's 200 carried a + // lease /finalize would refuse. Pinning them makes exactly one + // adopter the writer of any given generation. + assert.deepEqual(seen[0], { + id: "row-1", + storagePath: row.storagePath, + state: "NEEDS_REVIEW", + stateReason: "sha-mismatch", + claimToken: null, + uploadLeaseVersion: 2, + uploadLeaseNonce: CREATED_NONCE, + uploadUrlExpiresAt: row.uploadUrlExpiresAt, + }); +}); + +test("an extended lease whose URL cannot be signed is a 503, not a fall-through", async () => { + // Falling through would reach the destructive branch — which is the thing + // this module exists to keep away from a live lease. + const row = parked(); + const { store, deps } = client([row]); + const outcome = await reuseLiveLease(row, "png", { ...deps, sign: async () => null }); + assert.deepEqual(outcome, { kind: "storage-unavailable" }); + assert.equal(store.rows[0].uploadLeaseVersion, 2, "and the row still holds its lease"); +}); + +// ── The finding: a failed signer deleted a row another request had RESUMED ── + +/** The lease /start just wrote, as the request that wrote it knows it. */ +const asCreated = (row: LeaseFixture) => ({ + id: row.id, + storagePath: row.storagePath, + uploadLeaseVersion: row.uploadLeaseVersion, + uploadUrlExpiresAt: row.uploadUrlExpiresAt!, + uploadLeaseNonce: row.uploadLeaseNonce, +}); + +test("A RESUMED ROW SURVIVES the original request's signer failure", async () => { + // /start creates the row FIRST and signs its URL SECOND. In that gap a + // concurrent /start for the same sourceRef hits the unique violation, finds + // this row with a live lease, and reuseLiveLease hands it a WORKING signed + // URL over the same path. The unconditional delete this replaces then + // removed the row that retry had just adopted: its bytes landed at a path + // no row pointed at, /finalize 404'd on an id that no longer existed, and + // the sourceRef stopped protecting the document's identity. + const created = staging(); + const { store, deps } = client([created]); + + // The retry, interleaved BEFORE the original's discard. + const resumed = await reuseLiveLease(created, "png", deps); + assert.equal(resumed?.kind, "signed"); + + const outcome = await discardUnresumedLease(asCreated(created), deps.db); + assert.equal(outcome, "resumed", "the CAS refuses to delete a row somebody else adopted"); + assert.equal(store.rows.length, 1, "the row survives"); + assert.deepEqual(store.deleted, [], "and nothing is dropped"); + // The URL the retry is holding still names the path the surviving row + // points at, so its upload finalizes against a live row. + const signed = (resumed as { signed: { storagePath: string } }).signed; + assert.equal(signed.storagePath, store.rows[0].storagePath); + assert.equal(store.rows[0].state, "STAGING", "still resumable, not orphaned"); +}); + +test("with NOBODY resuming it, the row really is discarded", async () => { + // The control. Without it a CAS that matched nothing would pass the test + // above while leaking a STAGING row — and its sourceRef — on every signer + // failure. + const created = staging(); + const { store, deps } = client([created]); + assert.equal(await discardUnresumedLease(asCreated(created), deps.db), "discarded"); + assert.deepEqual(store.rows, [], "no row is left holding the sourceRef"); +}); + +test("a row the RESUME branch repathed is not deleted either", async () => { + // The other way a retry adopts the row: an expired lease, or a caller + // declaring a different extension, takes the destructive branch — new lease + // version, new path. Pinning the version and the path is what sees it. + const created = staging(); + const { store, deps } = client([created]); + store.rows[0].uploadLeaseVersion = 2; + store.rows[0].storagePath = uploadPathFor(created.id, 2, "png"); + + assert.equal(await discardUnresumedLease(asCreated(created), deps.db), "resumed"); + assert.equal(store.rows.length, 1); +}); + +test("a row that PUBLISHED under us is not deleted", async () => { + // /finalize can land between the create and the signer failure. Deleting a + // published row would destroy a receipt over a signing hiccup. + const created = staging(); + const { store, deps } = client([created]); + store.rows[0].state = "RECEIVED"; + assert.equal(await discardUnresumedLease(asCreated(created), deps.db), "resumed"); + assert.equal(store.rows.length, 1); +}); + +test("the discard CAS reads every column an adopter would have moved", async () => { + // Each is a witness for one way the row can be adopted: the version and + // path for a resume, the state for a publish, and the NONCE for a lease + // reuse -- which the expiry alone could not see, because a reuse writes the + // same "now + 2h" the original issue did. + const created = staging(); + const { store, deps } = client([created]); + for (const [column, value] of [ + ["uploadUrlExpiresAt", new Date(Date.now() + 3 * HOUR)], + ["uploadLeaseVersion", 9], + ["storagePath", "receipts/intake/row-1.v9.png"], + ["state", "NEEDS_REVIEW"], + ["uploadLeaseNonce", "lease-nonce-adopted-1"], + ] as const) { + store.rows = [{ ...created, [column]: value } as Record]; + assert.equal( + await discardUnresumedLease(asCreated(created), deps.db), + "resumed", + `a moved ${column} must lose the CAS`, + ); + assert.equal(store.rows.length, 1, column); + } +}); + +// -- The hole in the previous round's own fix: an expiry is not an identity --- + +test("SAME-MILLISECOND EXPIRY: an adopted row still survives the discard", async () => { + // The previous CAS pinned `uploadUrlExpiresAt` and argued the adopter's + // value must read strictly LATER, because it can only run after this + // request's INSERT committed. That is an argument about ORDER; the CAS needs + // INEQUALITY. Production issues both the initial and the resumed expiry as + // "now + 2h" and Date.now() has millisecond resolution, so two requests a + // few hundred microseconds apart write the SAME instant -- and the delete + // then removed a row the retry had already been handed a working URL for. + const created = staging(); + const { store, deps } = client([created]); + + // The adopter, with a clock that lands on the exact instant we wrote. + const sameInstant = { + ...deps, + expiresAt: () => new Date(created.uploadUrlExpiresAt!.getTime()), + }; + const resumed = await reuseLiveLease(created, "png", sameInstant); + assert.equal(resumed?.kind, "signed"); + // THE GENERATION NO LONGER MOVES on an extension — an extension is the + // same lease, and both retries have to be able to finalize under it. So + // the expiry is what the discard's witness has to be, and the adoption + // FORCES it past what it found rather than hoping the clock does. + assert.equal(store.rows[0].uploadLeaseNonce, CREATED_NONCE, "same lease, same generation"); + assert.equal( + (store.rows[0].uploadUrlExpiresAt as Date).getTime(), + created.uploadUrlExpiresAt!.getTime() + 1, + "one millisecond past the instant it found, by construction", + ); + + assert.equal(await discardUnresumedLease(asCreated(created), deps.db), "resumed"); + assert.equal(store.rows.length, 1, "the row survives"); + assert.deepEqual(store.deleted, [], "and nothing is dropped"); + const signed = (resumed as { signed: { storagePath: string } }).signed; + assert.equal(signed.storagePath, store.rows[0].storagePath, "the retry's URL still names a live row"); +}); + +test("CLOCK SKEW: an adoption never moves the expiry BACKWARDS", async () => { + // Two hosts, two clocks. The adopter's "now + 2h" can land BEFORE ours. + // Writing it would shorten a lease whose holder is still using a URL + // signed for a full window -- which is how the sweeper comes to reclaim a + // row somebody is actively uploading to. The extension takes the later of + // the two, and still moves it far enough for the discard to see. + const created = staging(); + const { store, deps } = client([created]); + const skewed = { + ...deps, + expiresAt: () => new Date(created.uploadUrlExpiresAt!.getTime() - 5 * 60_000), + }; + const resumed = await reuseLiveLease(created, "png", skewed); + assert.equal(resumed?.kind, "signed"); + assert.equal( + (store.rows[0].uploadUrlExpiresAt as Date).getTime(), + created.uploadUrlExpiresAt!.getTime() + 1, + "the skewed, EARLIER instant was refused; the lease only ever grows", + ); + + assert.equal(await discardUnresumedLease(asCreated(created), deps.db), "resumed"); + assert.equal(store.rows.length, 1); +}); + +test("the control, restated: with NO adoption the generation is untouched and the row goes", async () => { + // Without this, a CAS that matched nothing would pass both tests above while + // leaking a STAGING row -- and its sourceRef -- on every signer failure. + const created = staging(); + const { store, deps } = client([created]); + assert.equal(store.rows[0].uploadLeaseNonce, CREATED_NONCE); + assert.equal(await discardUnresumedLease(asCreated(created), deps.db), "discarded"); + assert.deepEqual(store.rows, []); +}); + +// -- Round 19: every 200 /start hands back must remain FINALIZABLE -------- +// +// The test this replaces asserted the opposite, and blessed the bug: it +// required two adoptions of the SAME live lease to stamp DIFFERENT +// generations. Only the last one is stored, /finalize demands the generation +// its URL was issued under, so the earlier caller -- holding a signed URL it +// had just been handed for the same path -- was answered 409 lease-stale. An +// endpoint whose entire purpose is idempotent retries was issuing responses +// that could never be used. + +/** What /finalize does with an echoed lease, in one line. */ +const finalizable = (store: Store, uploadLease: string) => + store.rows.length === 1 && store.rows[0].uploadLeaseNonce === uploadLease; + +test("CONCURRENT /start: both 200s carry the SAME lease, and both finalize", async () => { + const created = staging(); + const { store, deps } = client([created]); + + // Both requests read the row before either writes -- the actual shape of + // a double-tap, a network retry, or a forwarder's own retry policy. + const [a, b] = await Promise.all([ + reuseLiveLease(created, "png", deps), + reuseLiveLease(created, "png", deps), + ]); + + assert.equal(a?.kind, "signed", "the first retry got a URL"); + assert.equal(b?.kind, "signed", "and so did the second"); + const leaseA = (a as { signed: { uploadLease: string } }).signed.uploadLease; + const leaseB = (b as { signed: { uploadLease: string } }).signed.uploadLease; + + assert.equal(leaseA, leaseB, "one live lease, one generation"); + assert.equal(leaseA, CREATED_NONCE, "and it is the generation they adopted"); + // THE PROPERTY, stated as /finalize would evaluate it. + assert.ok(finalizable(store, leaseA), "the first response is still finalizable"); + assert.ok(finalizable(store, leaseB), "and so is the second"); + + // Same path, same version, nothing deleted -- the idempotency this rule + // exists for is intact. + assert.deepEqual(store.signed, [created.storagePath, created.storagePath]); + assert.equal(store.rows[0].uploadLeaseVersion, 1); + assert.deepEqual(store.deleted, []); +}); + +test("PRE-FIX CONTROL: minting a generation per adoption strands the first 200", async () => { + // The old rule, reproduced exactly: a fresh nonce on every adoption, and + // the nonce left out of the CAS so both writers land. + const created = staging(); + const { store, deps } = client([created]); + const oldRule = async (lease: string) => { + await deps.db.updateMany({ + where: { id: created.id, storagePath: created.storagePath, state: created.state }, + data: { uploadUrlExpiresAt: deps.expiresAt(), uploadLeaseNonce: lease }, + }); + return lease; + }; + + const first = await oldRule("lease-nonce-adopted-1"); + const second = await oldRule("lease-nonce-adopted-2"); + + assert.notEqual(first, second, "two 200s, two generations -- what shipped"); + assert.ok(finalizable(store, second), "the last writer's response works"); + assert.equal( + finalizable(store, first), + false, + "and the first caller's, handed out moments earlier, is dead on arrival", + ); +}); + +test("a lease SUPERSEDED during the signing round trip is never returned", async () => { + // The other side of the same failure: the CAS proves the lease was ours + // when we wrote it, and signing is a network round trip. A concurrent + // resume repaths the row while it is in flight, so the generation we were + // about to hand back is one the row has already moved past. + const created = staging(); + const { store, deps, onSign } = client([created]); + onSign(() => { + store.rows[0].uploadLeaseVersion = 2; + store.rows[0].storagePath = uploadPathFor("row-1", 2, "png"); + store.rows[0].uploadLeaseNonce = "lease-nonce-resumed"; + }); + + const outcome = await reuseLiveLease(created, "png", deps); + + assert.deepEqual(outcome, { kind: "conflict" }, "a retryable 409, never a stale 200"); +}); + +test("CONTROL: with nobody moving the row, the same call returns its lease", async () => { + // Without this, a revalidation that always failed would satisfy the test + // above while making every honest retry a 409. + const created = staging(); + const { store, deps } = client([created]); + const outcome = await reuseLiveLease(created, "png", deps); + assert.equal(outcome?.kind, "signed"); + const lease = (outcome as { signed: { uploadLease: string } }).signed.uploadLease; + assert.ok(finalizable(store, lease)); +}); + +test("a legacy row with NO generation gets one, and only one writer mints it", async () => { + // A row that predates the nonce column carries null. The adoption has to + // mint a value -- and the CAS pins the null, so two concurrent adopters + // cannot each mint their own and strand one another. + const legacy = staging({ uploadLeaseNonce: null as unknown as string }); + const { store, deps } = client([legacy]); + + const [a, b] = await Promise.all([ + reuseLiveLease(legacy, "png", deps), + reuseLiveLease(legacy, "png", deps), + ]); + + assert.equal(a?.kind, "signed"); + assert.equal(b?.kind, "signed"); + const leaseA = (a as { signed: { uploadLease: string } }).signed.uploadLease; + const leaseB = (b as { signed: { uploadLease: string } }).signed.uploadLease; + assert.equal(leaseA, leaseB, "they converge on the one that was minted"); + assert.ok(finalizable(store, leaseA)); +}); + +test("extendedExpiry on its own: never equal, never earlier", () => { + const base = new Date(1_000_000); + assert.equal(extendedExpiry(base, new Date(1_000_000)).getTime(), 1_000_001, "equal is not allowed"); + assert.equal(extendedExpiry(base, new Date(999_000)).getTime(), 1_000_001, "earlier is not allowed"); + assert.equal(extendedExpiry(base, new Date(2_000_000)).getTime(), 2_000_000, "later is taken as is"); + assert.equal(extendedExpiry(null, new Date(2_000_000)).getTime(), 2_000_000, "nothing to beat"); +}); + +test("the real generator is unique per call -- the fake's determinism is the test's", () => { + const seen = new Set(Array.from({ length: 200 }, () => newLeaseNonce())); + assert.equal(seen.size, 200); +}); + +// -- The three MINTING branches re-read before they answer ------------------ +// +// The create, the resume repath and the re-arm repath all write the row, then +// sign, then answer -- and the sign is a network round trip a concurrent +// /start can move the row inside. They were returning the nonce they had +// generated, never re-checked, so a client could be handed a working signed +// URL together with a lease /finalize had already moved past. Unlike the reuse +// rule they cannot simply loop (their write was destructive), so a superseded +// lease becomes the retryable publish-conflict the callers already answer. + +test("issuedLeaseIsCurrent: only the generation the row STILL holds is current", async () => { + const row = staging(); + const reload = async () => row as unknown as LeaseRow; + + assert.equal( + await issuedLeaseIsCurrent( + row.id, + { storagePath: row.storagePath, uploadLease: CREATED_NONCE }, + reload, + ), + true, + "the lease the row holds, at the path the row points at", + ); + + // A generation the row has moved past. THIS is the case the mutation + // survived on: /finalize refuses it, so returning it hands the client a URL + // it can never use. + assert.equal( + await issuedLeaseIsCurrent( + row.id, + { storagePath: row.storagePath, uploadLease: "a-generation-since-superseded" }, + reload, + ), + false, + ); + + // A row repathed under us: the lease may still match, the object does not. + assert.equal( + await issuedLeaseIsCurrent( + row.id, + { storagePath: uploadPathFor("row-1", 9, "png"), uploadLease: CREATED_NONCE }, + reload, + ), + false, + ); + + // A row that is gone entirely is never current. + assert.equal( + await issuedLeaseIsCurrent( + row.id, + { storagePath: row.storagePath, uploadLease: CREATED_NONCE }, + async () => null, + ), + false, + ); + + // And a row whose generation is NULL -- never issued a signed URL -- cannot + // match a lease somebody claims to hold. + assert.equal( + await issuedLeaseIsCurrent( + row.id, + { storagePath: row.storagePath, uploadLease: CREATED_NONCE }, + async () => ({ ...row, uploadLeaseNonce: null }) as unknown as LeaseRow, + ), + false, + ); +}); + +test("an extension never REWRITES the announced hash, only adopts a missing one", async () => { + // sha256Agrees compares case-insensitively, because a client that + // upper-cases its digest is announcing the same document. That agreement + // must not become a WRITE: the stored value is what /finalize compares the + // computed digest against, and the row's own canonical form is the one it + // was published with. An extension is not a place to restate identity -- + // its whole job is to leave identity alone. + const CANONICAL = "a".repeat(64); + const SHOUTED = CANONICAL.toUpperCase(); + const row = parked({ storagePath: uploadPathFor("row-1", 2, "png"), expectedSha256: CANONICAL }); + const { store, deps } = client([row]); + + const outcome = await reuseLiveLease(row, "png", deps, {}, SHOUTED); + + assert.equal(outcome!.kind, "signed", "the same document, differently spelled, still agrees"); + assert.equal( + store.rows[0].expectedSha256, + CANONICAL, + "and the stored hash is untouched -- the row keeps the form it published", + ); +}); diff --git a/tests/receipt-intake-worker.test.ts b/tests/receipt-intake-worker.test.ts new file mode 100644 index 000000000..4bb3dfb76 --- /dev/null +++ b/tests/receipt-intake-worker.test.ts @@ -0,0 +1,2232 @@ +/** + * The worker pass, and the one property the whole shadow week rests on: + * + * with RECEIPT_INTAKE_DRYRUN unset (the default), a row is read, deduped and + * routed, and NOTHING is booked — zero createPurchase calls, zero Expense + * rows. + * + * That is asserted by counting the injected fakes' calls, not by reading the + * code. Dependency injection throughout; no `mock.module` (CI is Node 20). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + runIntakeWorker, + dateOnly, + isTerminalQboFault, + isUniqueViolation, + toDateStr, + MAX_BUSY_PASSES, + MAX_PLAUSIBLE_TAX_RATE, + validateTaxCents, + RUN_SOFT_DEADLINE_MS, + type ReadPatch, + type WorkerDependencies, + type WorkerRow, + uploadLeaseActive, + storageTimeoutRun, + uploadLeaseExpiry, + SIGNED_UPLOAD_TTL_MS, + readBudgetFor, + READ_MIN_BUDGET_MS, + READ_SAFETY_MARGIN_MS, + claimableStates, + eligibleClaimWhere, + BATCH_SIZE, + DRYRUN_PARK_RETRY_MS, + QBO_WRITING_STATES, +} from "../src/lib/receipt-intake/worker"; +import { preservedTaxWarning } from "../src/lib/receipt-intake/route-state"; +import { normalizeDocType, READ_BUDGET_MS, type ReadOutcome } from "../src/lib/receipt-intake/read"; +import type { BookResult } from "../src/lib/receipt-intake/book"; +import type { CutoverRequest } from "../src/lib/receipt-intake/worker"; +import { QBTimeoutError } from "../src/lib/quickbooks"; +import { + downloadReceiptObject, + storageBudgetMs, + STORAGE_CALL_MAX_MS, +} from "../src/lib/receipt-intake/bucket"; +import { QboAccountConfigError, QboPurchaseFaultError } from "../src/lib/qbo-receipt-push"; + +import { Prisma } from "@prisma/client"; + +const PrismaKnownError = Prisma.PrismaClientKnownRequestError; +const NOW = new Date("2026-09-01T12:00:00.000Z"); +/** The token this pass claims with. A row carrying anything else is a successor's. */ +const LIVE_TOKEN = "claim-1"; + +function workerRow(overrides: Partial = {}): WorkerRow { + return { + id: "row-1", + source: "drive", + sourceRef: "drive:FILE1", + state: "RECEIVED", + dryRun: true, + projectId: "proj-1", + costCodeId: null, + suggestedCostCodeId: null, + storagePath: "receipts/intake/row-1.jpg", + fileName: "r.jpg", + mimeType: "image/jpeg", + fileSize: 100, + vendor: null, + txnDate: null, + totalCents: null, + taxCents: null, + docType: null, + refNumber: null, + memo: null, + attempts: 0, + readAt: null, + createdAt: new Date("2026-08-20T09:00:00.000Z"), + dedupWeakKey: null, + busyPasses: 0, + lastError: null, + suggestedConfidence: null, + sendAttempted: false, + claimToken: LIVE_TOKEN, + fileSha256: "s".repeat(64), + stateReason: null, + ...overrides, + }; +} + +const goodRead: ReadOutcome = { + ok: true, + read: { + docType: "receipt", + vendor: "Lowes", + date: "2026-08-03", + invoice: "82766", + checkNumber: "", + memo: "", + totalAmount: "364.98", + taxAmount: "29.20", + suggestedPhaseCode: "03-PLUMB", + suggestedConfidence: 0.82, + raw: '{"vendor":"Lowes"}', + }, +}; + +interface Harness { + deps: WorkerDependencies; + reads: number; + books: number; + applied: ReadPatch[]; + states: { + id: string; state: string; reason: string | null; + patch?: Partial; ownership?: { state: string; claimToken: string | null }; + }[]; + promoted: string[]; + finished: { + id: string; + claimToken: string | null; + stateReason: string | null; + /** The DURABLE marker routing wrote, distinct from the display copy. */ + taxWarning: string | null; + }[]; + deferred: { id: string; busyPasses: number }[]; + retried: { id: string; attempts: number; reason: string }[]; + releasedClaims: { id: string; nextRetryAt: Date }[]; + releasedUnprocessed: { id: string; claimToken: string | null }[]; + leaseAcquires: number; + leaseReleases: number; + claimOpts: CutoverRequest[]; + boundary: Date | null; + sweepCalls: number; + cleanupCalls: number; + bookBudgets: number[]; + clock: number; + sendReads: string[]; + persistedSendAttempted?: boolean; +} + +function harness(rows: WorkerRow[], overrides: Partial = {}): Harness { + const h: Harness = { + reads: 0, books: 0, applied: [], states: [], promoted: [], finished: [], deferred: [], + retried: [], releasedClaims: [], releasedUnprocessed: [], leaseAcquires: 0, leaseReleases: 0, + claimOpts: [], sweepCalls: 0, cleanupCalls: 0, bookBudgets: [], clock: 0, + sendReads: [], + boundary: new Date("2026-08-25T00:00:00.000Z"), + deps: null as unknown as WorkerDependencies, + }; + h.deps = { + // The default harness always gets the lease. The tests that care about + // overlap override it. + acquireLease: async () => { + h.leaseAcquires++; + return { release: async () => { h.leaseReleases++; } }; + }, + claim: async opts => { + h.claimOpts.push(opts); + return { rows, shadowRetired: 0, requeued: 0, shadowQuarantined: 0, shadowSkippedMoved: 0 }; + }, + cutoverBoundary: async () => h.boundary, + isDryRunEnabled: () => true, + sweepStaleStaging: async () => { h.sweepCalls++; return 0; }, + retryStorageCleanups: async () => { h.cleanupCalls++; return 0; }, + loadPhases: async () => [{ id: "cc-plumb", code: "03-PLUMB", name: "Plumbing" }], + // Defaults to what the row already carries: the interesting case is the + // one that overrides it, where a late assignment landed mid-pass. + refreshProjectId: async rowId => rows.find(r => r.id === rowId)?.projectId ?? null, + // The PERSISTED flag. Defaults to what the row carries, so only the + // tests about the reload have to think about it. + sendAttemptedNow: async rowId => { + h.sendReads.push(rowId); + return h.persistedSendAttempted ?? rows.find(r => r.id === rowId)?.sendAttempted ?? false; + }, + downloadBytes: async () => ({ ok: true as const, bytes: Buffer.from("bytes") }), + read: async () => { h.reads++; return goodRead; }, + applyRead: async (_id, patch) => { h.applied.push(patch); return { owned: true, strongOwner: null }; }, + findWeakHit: async () => null, + applyState: async (id, state, reason, patch, ownership) => { + h.states.push({ id, state, reason, patch, ownership }); + return true; + }, + finishRouting: async (id, claimToken, stateReason, taxWarning) => { + h.finished.push({ id, claimToken, stateReason, taxWarning }); + }, + companyTimeZone: async () => "America/Los_Angeles", + promoteToBooking: async id => { h.promoted.push(id); return { promoted: true }; }, + book: async () => { + h.books++; + return { outcome: "booked", qbPurchaseId: "QB-1", expenseId: "e1", alreadyExisted: false } as BookResult; + }, + applyBookResult: async () => {}, + deferRead: async (id, busyPasses) => { h.deferred.push({ id, busyPasses }); return true; }, + releaseClaim: async (id, nextRetryAt) => { h.releasedClaims.push({ id, nextRetryAt }); return true; }, + // Token-fenced in the real implementation; here it just records what + // was handed back, and reports the rows whose token still matches. + releaseUnprocessed: async released => { + h.releasedUnprocessed.push(...released); + return released.filter(r => r.claimToken === LIVE_TOKEN).length; + }, + retryRow: async (id, attempts, _next, reason) => { h.retried.push({ id, attempts, reason }); return true; }, + now: () => NOW, + monotonicMs: () => h.clock, + ...overrides, + }; + return h; +} + +test("DRY RUN: a received row is read, deduped and routed — and never booked", async () => { + const h = harness([workerRow({ dryRun: true })]); + const summary = await runIntakeWorker(h.deps); + + assert.equal(h.reads, 1, "the reader DOES run in shadow mode — that is the point"); + assert.equal(h.books, 0, "zero booking calls"); + assert.equal(h.applied.length, 1); + // The claim leaves the row RECEIVED and holding its lease; finishRouting is + // the only thing that publishes READ, after every dedup net has answered. + assert.equal(h.applied[0].state, "RECEIVED"); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: null, taxWarning: null }]); + assert.equal(h.applied[0].vendor, "Lowes"); + assert.equal(h.applied[0].totalCents, 36498); + assert.equal(h.applied[0].taxCents, 2920); + assert.equal(h.applied[0].dedupStrongKey, "2026-08-03|82766"); + assert.equal(h.applied[0].dedupWeakKey, "lowes|2026-08-03|364.98|amt"); + assert.equal(h.applied[0].suggestedCostCodeId, "cc-plumb"); + assert.deepEqual(summary, { processed: 1, byState: { READ: 1 } }); +}); + +test("DRY RUN: a row already at READ parks there instead of moving to BOOKING", async () => { + const h = harness([workerRow({ state: "READ", dryRun: true })]); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.books, 0); + assert.deepEqual(h.promoted, []); + assert.deepEqual(summary.byState, { READ: 1 }); +}); + +test("DRY RUN: a row stuck at BOOKING is not booked either", async () => { + const h = harness([workerRow({ state: "BOOKING", dryRun: true })]); + await runIntakeWorker(h.deps); + assert.equal(h.books, 0); +}); + +test("LIVE: a READ row with dryRun=false is promoted and booked", async () => { + const h = harness([workerRow({ state: "READ", dryRun: false })], { isDryRunEnabled: () => false }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(h.promoted, ["row-1"]); + assert.equal(h.books, 1); + assert.deepEqual(summary.byState, { BOOKED: 1 }); +}); + +test("the global kill switch parks a dryRun=false row at READ, not just BOOKING", async () => { + // The row's persisted flag is snapshotted once at intake, so it is not + // itself a kill switch: reverting RECEIPT_INTAKE_DRYRUN to stop live QBO + // writes must still stop rows claimed dryRun=false before the switch was + // reverted — the row flag alone must never be trusted over the current + // global switch. + const h = harness([workerRow({ state: "READ", dryRun: false })], { isDryRunEnabled: () => true }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(h.promoted, [], "never even promoted to BOOKING"); + assert.equal(h.books, 0, "the QBO purchase path is never called"); + assert.deepEqual(summary.byState, { READ: 1 }); +}); + +test("the global kill switch parks a dryRun=false row already at BOOKING", async () => { + const h = harness([workerRow({ state: "BOOKING", dryRun: false })], { isDryRunEnabled: () => true }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.books, 0, "the QBO purchase path is never called"); + assert.deepEqual(summary.byState, { BOOKING: 1 }); +}); + +test("a strong-key claim that loses re-routes against the owner and keeps no key", async () => { + // Same total AND same canonical vendor: a confirmed duplicate. + const h = harness([workerRow()], { + applyRead: async () => ({ owned: true, strongOwner: { id: "row-owner", totalCents: 36498, canonicalVendor: "lowes" } }), + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { DUPLICATE: 1 }); + assert.equal(h.states.length, 1); + assert.equal(h.states[0].state, "DUPLICATE"); +}); + +test("a strong-key loss at a DIFFERENT total goes to a human, not to DUPLICATE", async () => { + const h = harness([workerRow()], { + applyRead: async () => ({ owned: true, strongOwner: { id: "row-owner", totalCents: 999, canonicalVendor: "lowes" } }), + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); + assert.equal(h.states[0].reason, "strong-dup-amount-mismatch:row-owner"); +}); + +test("a document that does not reach READ never claims the strong key", async () => { + // A multi-doc or a $0 misread holding "2026-08-03|82766" would quarantine + // the real receipt that arrives next. + const h = harness([workerRow()], { + read: async () => ({ ...goodRead, read: { ...goodRead.read, docType: "multi" } }) as ReadOutcome, + }); + await runIntakeWorker(h.deps); + // Through applyState, which RELEASES the claim in the same write — not + // applyRead, which keeps the lease because routing continues under it. + assert.deepEqual(h.applied, [], "no lease-keeping write for a finished row"); + assert.equal(h.states[0].state, "NEEDS_REVIEW"); + assert.equal(h.states[0].reason, "multi-doc"); + assert.equal(h.states[0].patch?.dedupStrongKey, null); +}); + +test("a service outage costs no attempt: the row is deferred and counts ONE busy pass", async () => { + const h = harness([workerRow({ busyPasses: 3 })], { read: async () => ({ ok: false, decisive: false }) }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(h.deferred, [{ id: "row-1", busyPasses: 4 }]); + assert.deepEqual(h.states, [], "no state change — the document was never read"); + assert.deepEqual(summary.byState, { RECEIVED: 1 }); +}); + +test("an outage that never ends still ends: 20 busy passes parks the row", async () => { + // v3.4. Without a ceiling a row cycles silently forever and nobody is ever + // told the pipeline stopped producing. + const h = harness([workerRow({ busyPasses: MAX_BUSY_PASSES - 1 })], { + read: async () => ({ ok: false, decisive: false }), + }); + await runIntakeWorker(h.deps); + assert.deepEqual(h.deferred, [], "no further deferral"); + assert.equal(h.states[0].state, "NEEDS_REVIEW"); + assert.equal(h.states[0].reason, "ai-unavailable"); +}); + +test("a document the model answered on but could not read goes to a human", async () => { + const h = harness([workerRow()], { read: async () => ({ ok: false, decisive: true }) }); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].state, "NEEDS_REVIEW"); + assert.equal(h.states[0].reason, "unreadable"); +}); + +test("a missing storage object is terminal, not an infinite read loop", async () => { + const h = harness([workerRow()], { + downloadBytes: async () => ({ ok: false as const, kind: "missing" as const }), + }); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].reason, "file-missing"); + assert.equal(h.reads, 0); +}); + +test("a TRANSIENT storage fault retries — it is not evidence the file is gone", async () => { + // Collapsing both to null meant a Supabase blip parked good receipts as + // file-missing, permanently, for a human to untangle. + const h = harness([workerRow({ attempts: 1 })], { + downloadBytes: async () => ({ ok: false as const, kind: "transient" as const, message: "ECONNRESET" }), + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { RETRY: 1 }); + assert.deepEqual(h.states, [], "not parked"); + assert.equal(h.retried[0].attempts, 2); + assert.match(h.retried[0].reason, /^storage:/); +}); + +test("another run holding the lock yields skipped, not an empty pass", async () => { + const h = harness([], { claim: async () => null }); + assert.deepEqual(await runIntakeWorker(h.deps), { + processed: 0, byState: {}, skipped: "already-running", + }); +}); + +test("one blowing-up row does not stall the batch", async () => { + // The failing row is RETRIED (a throw here is almost always transport, not + // the document) and, either way, row 2 still gets processed. + let call = 0; + const h = harness([workerRow({ id: "row-1" }), workerRow({ id: "row-2" })], { + read: async () => { + call++; + if (call === 1) throw new Error("boom"); + return goodRead; + }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.processed, 2); + assert.equal(summary.byState.RETRY, 1); + assert.equal(summary.byState.READ, 1); + assert.equal(h.retried[0].id, "row-1"); +}); + +test("a strong-key loss to a DIFFERENT vendor is a collision, not a duplicate", async () => { + const h = harness([workerRow()], { + applyRead: async () => ({ owned: true, strongOwner: { id: "row-owner", totalCents: 36498, canonicalVendor: "homedepot" } }), + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); + assert.equal(h.states[0].reason, "vendor-mismatch:row-owner"); +}); + +// ── Dry-run starvation (Codex blocker 1) ───────────────────────────────────── + +test("the shadow week does NOT run the cutover", async () => { + const h = harness([workerRow({ state: "READ", dryRun: true })], { isDryRunEnabled: () => true }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.claimOpts[0].dryRunGlobal, true); + assert.equal(h.claimOpts[0].boundary, null, "the boundary is not even read while dry-run is on"); + assert.equal(summary.shadowRetired, undefined); +}); + +test("CUTOVER: the boundary is passed to the claim so the backlog can be split", async () => { + // The double-booking hazard this closes: v2's QBO identity for an + // email/chat/mobile/web row is the intake UUID, which v1 never saw, so + // QuickBooks' DocNumber idempotency could not recognise the Purchase v1 + // already made — and requeuing would have booked the entire shadow backlog + // a second time, on real books, in one pass. + const boundary = new Date("2026-08-25T00:00:00.000Z"); + const h = harness([], { + isDryRunEnabled: () => false, + cutoverBoundary: async () => boundary, + claim: async opts => { + h.claimOpts.push(opts); + // Rows BEFORE the boundary were booked by v1; rows after it by nobody. + return { rows: [], shadowRetired: 7, requeued: 2, shadowQuarantined: 0, shadowSkippedMoved: 0 }; + }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.claimOpts[0].dryRunGlobal, false); + assert.equal(h.claimOpts[0].boundary?.toISOString(), boundary.toISOString()); + assert.equal(summary.shadowRetired, 7, "v1 already booked these"); + assert.equal(summary.requeued, 2, "nobody booked these — v2 must"); + assert.equal(summary.cutoverBlocked, undefined); +}); + +test("CUTOVER refuses entirely when no boundary is recorded", async () => { + // Nothing in the database can infer when v1 stopped booking. Retiring on a + // guess destroys evidence of real expenses; requeuing on a guess + // double-books them. A logged no-op is the only honest third option. + const h = harness([], { + isDryRunEnabled: () => false, + cutoverBoundary: async () => null, + claim: async opts => { + h.claimOpts.push(opts); + assert.equal(opts.boundary, null); + return { rows: [], shadowRetired: 0, requeued: 0, shadowQuarantined: 0, shadowSkippedMoved: 0 }; + }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.cutoverBlocked, "cutover-boundary-missing"); + assert.equal(summary.shadowRetired, undefined); + assert.equal(summary.requeued, undefined); +}); + +test("a run that loses the lock does nothing at all — including the cutover", async () => { + // The cutover is part of the claim transaction, so losing the lock means + // losing it too. That is correct: the run that HOLDS the lock does it. + const h = harness([], { isDryRunEnabled: () => false, claim: async () => null }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary, { processed: 0, byState: {}, skipped: "already-running" }); + assert.equal(h.sweepCalls, 0, "no work of any kind happens without the lock"); +}); + +// ── STAGING sweep (Codex round 2, blocker 1) ───────────────────────────────── + +test("every pass sweeps STAGING rows whose upload never landed", async () => { + // A STAGING row is invisible to the claim by design (its object is not in + // the bucket), so without this sweep nothing would ever notice one. + const h = harness([], { sweepStaleStaging: async () => { h.sweepCalls++; return 2; } }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.sweepCalls, 1); + assert.equal(summary.staleStagingSwept, 2); +}); + +test("a failing sweep never takes the pass down with it", async () => { + const h = harness([workerRow()], { + sweepStaleStaging: async () => { throw new Error("db blip"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.staleStagingSwept, undefined); + assert.deepEqual(summary.byState, { READ: 1 }, "the batch still ran"); +}); + +// ── Soft deadline (Codex blocker 2) ────────────────────────────────────────── + +test("the worker stops TAKING rows at 40s and leaves the rest for the next run", async () => { + // A row started at 41s can still be reading at 66s, past the 60s function + // ceiling — the invocation dies mid-book and the row is left in whatever + // state it happened to reach. + const rows = [1, 2, 3, 4, 5].map(n => workerRow({ id: `row-${n}` })); + const h = harness(rows, { + read: async () => { h.clock += 15_000; h.reads++; return goodRead; }, + }); + const summary = await runIntakeWorker(h.deps); + assert.ok(h.clock >= RUN_SOFT_DEADLINE_MS); + assert.equal(summary.processed, 3, "three rows fit inside the soft deadline"); + assert.equal(summary.deferredToNextRun, 2); + assert.equal(h.reads, 3, "the deferred rows are never read"); + + // ...AND THE TWO IT NEVER REACHED ARE HANDED BACK. + // + // The claim stamps all ten rows with a ten-minute lease. A row the loop + // never touched that keeps that lease AND its claim token is invisible to + // the next cron five minutes later — `eligibleClaimWhere` skips a future + // `nextRetryAt`, and every fenced write misses a token no live pass holds. + // A batch that spent its budget on row 3 sat on seven untouched receipts + // for the rest of the ten minutes. + assert.deepEqual( + h.releasedUnprocessed.map(r => r.id), + ["row-4", "row-5"], + "exactly the rows nothing was attempted against — the processed ones release themselves", + ); + assert.equal(summary.releasedUnprocessed, 2); +}); + +test("the release is FENCED: a row whose token changed is handed to the release and refused by it", async () => { + // The fence lives in the UPDATE's where clause, so what this proves at the + // worker level is that the token the pass claimed with travels with the + // row — a release keyed on the id alone would clear a claim a successor + // now holds. + const rows = [ + workerRow({ id: "row-1" }), + workerRow({ id: "row-2" }), + // Taken over between the claim and the deadline. + workerRow({ id: "row-3", claimToken: "claim-2" }), + ]; + const h = harness(rows, { + read: async () => { h.clock += 45_000; h.reads++; return goodRead; }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.processed, 1); + assert.deepEqual( + h.releasedUnprocessed, + [{ id: "row-2", claimToken: LIVE_TOKEN }, { id: "row-3", claimToken: "claim-2" }], + "the release is told each row's own token, not just its id", + ); + assert.equal(summary.releasedUnprocessed, 1, "only the row this pass still owns was released"); +}); + +test("no deadline, no release call: a batch that finishes hands nothing back", async () => { + const h = harness([workerRow(), workerRow({ id: "row-2" })]); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.processed, 2); + assert.equal(summary.deferredToNextRun, undefined); + assert.deepEqual(h.releasedUnprocessed, [], "every row completed under its own transition"); + assert.equal(summary.releasedUnprocessed, undefined); +}); + +test("a failing release never takes the pass down with it", async () => { + const rows = [1, 2, 3].map(n => workerRow({ id: `row-${n}` })); + const h = harness(rows, { + read: async () => { h.clock += 45_000; h.reads++; return goodRead; }, + releaseUnprocessed: async () => { throw new Error("db blip"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.deferredToNextRun, 2, "the rows are still reported as deferred"); + assert.equal(summary.releasedUnprocessed, undefined, "and honestly reported as NOT released"); +}); + +// ── Weak-dedup race at the READ -> BOOKING transition (Codex blocker 5) ─────── + +test("two rows sharing a weak key SERIALIZE: the second is blocked, not booked", async () => { + // Write skew. Both rows pass the read-time weak check (neither is BOOKING + // yet), so without the per-weak-key advisory lock inside promoteToBooking + // both SELECTs run before either UPDATE commits, READ COMMITTED sees no + // conflict (neither row writes what the other read), and the SAME purchase + // books twice. The lock is what makes the second one observe the first. + const WEAK = "lowes|2026-08-03|364.98|amt"; + const booking = new Set(); + const h = harness( + [ + workerRow({ id: "row-a", state: "READ", dryRun: false, dedupWeakKey: WEAK }), + workerRow({ id: "row-b", state: "READ", dryRun: false, dedupWeakKey: WEAK }), + ], + { + isDryRunEnabled: () => false, + // Stands in for the serialized transaction: the lock means this + // body runs to completion for row-a before row-b enters it. + promoteToBooking: async (id, weakKey) => { + h.promoted.push(id); + const twin = [...booking].find(other => other !== id); + if (weakKey && twin) return { promoted: false, conflictId: twin }; + booking.add(id); + return { promoted: true }; + }, + }, + ); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(h.promoted, ["row-a", "row-b"], "both rows attempted the transition"); + assert.equal(h.books, 1, "exactly ONE of them books"); + assert.equal(summary.byState.BOOKED, 1); + assert.equal(summary.byState.NEEDS_REVIEW, 1); +}); + +test("rows with DIFFERENT weak keys never block each other", async () => { + const h = harness([ + workerRow({ id: "row-a", state: "READ", dryRun: false, dedupWeakKey: "lowes|2026-08-03|364.98|amt" }), + workerRow({ id: "row-b", state: "READ", dryRun: false, dedupWeakKey: "amazon|2026-08-03|12.00|amt" }), + ], { isDryRunEnabled: () => false }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.books, 2); + assert.deepEqual(summary.byState, { BOOKED: 2 }); +}); + +test("a weak-key twin already BOOKING blocks the transition and asks a human", async () => { + const h = harness([workerRow({ state: "READ", dryRun: false, dedupWeakKey: "lowes|2026-08-03|364.98|amt" })], { + isDryRunEnabled: () => false, + promoteToBooking: async (id, weakKey) => { + h.promoted.push(id); + assert.equal(weakKey, "lowes|2026-08-03|364.98|amt", "the weak key is passed INTO the transition"); + return { promoted: false, conflictId: "row-twin" }; + }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.books, 0, "money never moves on a blocked transition"); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); +}); + +// ── Transient vs terminal (Codex issue 11) ─────────────────────────────────── + +test("a storage/Prisma/network throw is RETRIED, not parked for a human", async () => { + // Parking every transient fault turns one bad minute into a queue full of + // manual work — and leaves those rows holding their strong keys. + for (const error of [new Error("connection reset"), new TypeError("fetch failed"), new QBTimeoutError("t")]) { + const h = harness([workerRow({ attempts: 2 })], { + downloadBytes: async () => { throw error; }, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { RETRY: 1 }, String(error)); + assert.equal(h.retried[0].attempts, 3); + assert.deepEqual(h.states, [], "not parked"); + } +}); + +test("a CLASSIFIED QBO business fault thrown mid-row IS terminal", () => { + assert.equal(isTerminalQboFault(new QboPurchaseFaultError(400, "closed period", "6210")), true); + assert.equal(isTerminalQboFault(new QboAccountConfigError("bad account")), true); + // A timeout is transport, not a verdict. + assert.equal(isTerminalQboFault(new QBTimeoutError("timed out")), false); + assert.equal(isTerminalQboFault(new Error("connection reset")), false); +}); + +test("a QBO fault thrown mid-row parks; a transient one past the ceiling also parks", async () => { + const terminal = harness([workerRow()], { + downloadBytes: async () => { throw new QboAccountConfigError("bad account"); }, + }); + assert.deepEqual((await runIntakeWorker(terminal.deps)).byState, { NEEDS_REVIEW: 1 }); + assert.match(terminal.states[0].reason!, /^qbo-fault:/); + + const exhausted = harness([workerRow({ attempts: 19 })], { + downloadBytes: async () => { throw new Error("connection reset"); }, + }); + assert.deepEqual((await runIntakeWorker(exhausted.deps)).byState, { NEEDS_REVIEW: 1 }); + assert.equal(exhausted.states[0].reason, "max-retries"); +}); + +// ── A failure AFTER the READ -> BOOKING promotion (Codex round-36 item 1) ──── +// +// The promotion COMMITS a state change mid-row. Every recovery write is CAS'd +// on the row's {state, claimToken}, so handing the error path the row as it was +// CLAIMED pinned "READ" against a database that now said "BOOKING": zero rows +// matched, `attempts` never moved, and the row came back next pass to fail the +// same way forever without ever reaching max-retries. + +/** + * A harness whose recovery writes really evaluate the CAS, against a database + * state the promotion actually moves. Without that the fakes accept any + * ownership and the bug is invisible — which is how it survived 35 rounds. + */ +type Ownership = { state: string; claimToken: string | null }; + +function promotedHarness(row: WorkerRow, thrown: unknown) { + const db: Ownership = { state: row.state, claimToken: row.claimToken }; + const seen: Ownership[] = []; + /** What `updateMany({ where: { id, state, claimToken } })` would match. */ + const wouldMatch = (o: Ownership) => o.state === db.state && o.claimToken === db.claimToken; + const cas = (ownership: Ownership) => { + seen.push(ownership); + return wouldMatch(ownership); + }; + const h = harness([row], { + isDryRunEnabled: () => false, + promoteToBooking: async id => { + h.promoted.push(id); + db.state = "BOOKING"; + return { promoted: true }; + }, + book: async () => { throw thrown; }, + retryRow: async (id, attempts, _next, reason, ownership) => { + if (!cas(ownership)) return false; + h.retried.push({ id, attempts, reason }); + return true; + }, + applyState: async (id, state, reason, patch, ownership) => { + if (!cas(ownership!)) return false; + h.states.push({ id, state, reason, patch, ownership }); + return true; + }, + }); + return { h, db, seen, wouldMatch }; +} + +test("a throw right after the promotion spends an attempt against the BOOKING row", async () => { + const row = workerRow({ state: "READ", dryRun: false, attempts: 2 }); + const { h, db, seen, wouldMatch } = promotedHarness(row, new Error("connection reset")); + + const summary = await runIntakeWorker(h.deps); + + assert.deepEqual(summary.byState, { RETRY: 1 }, "retried, not silently stale"); + assert.equal(h.retried.length, 1); + assert.equal(h.retried[0].attempts, 3, "the attempt actually landed"); + assert.equal(db.state, "BOOKING", "the promotion committed"); + assert.deepEqual(seen[0], { state: "BOOKING", claimToken: LIVE_TOKEN }, "the CAS pinned the CURRENT state"); + + // THE CONTROL. The old code passed the row as CLAIMED, so its CAS pinned + // "READ" — assert directly that such a write would have matched zero rows. + // Without this the assertion above would also pass for a harness that + // ignored the CAS entirely, which is what let the bug live for 35 rounds. + assert.equal( + wouldMatch({ state: row.state, claimToken: row.claimToken }), + false, + "the pre-promotion ownership matches nothing once the promotion has committed", + ); +}); + +test("at the ceiling, a post-promotion failure PARKS instead of cycling forever", async () => { + // The consequence of the bug, not just its mechanism: with attempts frozen + // the row could never reach MAX_BOOK_ATTEMPTS, so the terminal park that + // puts it in front of a person was unreachable. + const row = workerRow({ state: "READ", dryRun: false, attempts: 19 }); + const { h, seen } = promotedHarness(row, new Error("connection reset")); + + const summary = await runIntakeWorker(h.deps); + + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); + assert.equal(h.states.length, 1); + assert.equal(h.states[0].reason, "max-retries"); + assert.deepEqual(seen.at(-1), { state: "BOOKING", claimToken: LIVE_TOKEN }); +}); + +test("a CLASSIFIED QBO fault after the promotion parks under the BOOKING state too", async () => { + // The terminal branch takes the same row, so it needs the same fix — and a + // qbo-fault park is the one that must NOT be lost: it means a send happened. + const row = workerRow({ state: "READ", dryRun: false }); + const { h } = promotedHarness(row, new QboAccountConfigError("bad account")); + + assert.deepEqual((await runIntakeWorker(h.deps)).byState, { NEEDS_REVIEW: 1 }); + assert.match(h.states[0].reason!, /^qbo-fault:/); + assert.deepEqual(h.states[0].ownership, { state: "BOOKING", claimToken: LIVE_TOKEN }); +}); + +test("a row claimed AT BOOKING is unaffected — its state never moves mid-pass", async () => { + // The control for the change itself: only the READ branch promotes, so the + // BOOKING branch must still CAS on the state it was claimed with. + const row = workerRow({ state: "BOOKING", dryRun: false, attempts: 0 }); + const { h, seen } = promotedHarness(row, new Error("connection reset")); + + assert.deepEqual((await runIntakeWorker(h.deps)).byState, { RETRY: 1 }); + assert.deepEqual(h.promoted, [], "no promotion happens on this branch"); + assert.deepEqual(seen[0], { state: "BOOKING", claimToken: LIVE_TOKEN }); +}); + +test("isUniqueViolation is about the ERROR CODE, not Prisma's meta text", () => { + // The previous version string-matched "dedupStrongKey" inside error.meta, + // which is version-dependent and EMPTY for a partial index on some engine + // builds — i.e. exactly the index this mechanism depends on. + const p2002 = Object.assign(new Error("unique"), { code: "P2002", meta: {}, clientVersion: "5", name: "PrismaClientKnownRequestError" }); + Object.setPrototypeOf(p2002, PrismaKnownError.prototype); + assert.equal(isUniqueViolation(p2002), true, "an empty meta must still be recognised"); + const p2003 = Object.assign(new Error("fk"), { code: "P2003", meta: {}, clientVersion: "5" }); + Object.setPrototypeOf(p2003, PrismaKnownError.prototype); + assert.equal(isUniqueViolation(p2003), false); + assert.equal(isUniqueViolation(new Error("plain")), false); +}); + +test("dateOnly anchors the calendar day in the COMPANY time zone, not UTC", () => { + // The bug: 2026-08-03 was stored as 2026-08-03T00:00:00Z, which in + // America/Los_Angeles is 5pm on August 2nd. Every report that bounds by + // LOCAL midnight — job cost by month, the WA tax period, variance by week — + // put roughly a third of receipts one day early, invisibly. + const pacific = dateOnly("2026-08-03", "America/Los_Angeles")!; + assert.equal(pacific.toISOString(), "2026-08-03T07:00:00.000Z", "local midnight PDT"); + + // The proof that matters: read back IN the company zone it is still the 3rd. + const asLocalDay = new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", + }).format(pacific); + assert.equal(asLocalDay, "2026-08-03"); + + // The old UTC-midnight value would have read as the 2nd — the regression. + const utcMidnight = new Date("2026-08-03T00:00:00.000Z"); + assert.equal( + new Intl.DateTimeFormat("en-CA", { + timeZone: "America/Los_Angeles", year: "numeric", month: "2-digit", day: "2-digit", + }).format(utcMidnight), + "2026-08-02", + "control: this is exactly what was wrong", + ); + + // Winter, so the offset differs (PST, -08:00) — a hardcoded offset would fail here. + assert.equal(dateOnly("2026-01-15", "America/Los_Angeles")!.toISOString(), "2026-01-15T08:00:00.000Z"); + // A zone east of UTC moves the other way. + assert.equal(dateOnly("2026-08-03", "Europe/Berlin")!.toISOString(), "2026-08-02T22:00:00.000Z"); + + assert.equal(dateOnly("2026-13-03", "America/Los_Angeles"), null); + assert.equal(dateOnly("nope", "America/Los_Angeles"), null); + assert.equal(toDateStr(new Date("2026-08-03T23:59:00.000Z")), "2026-08-03"); +}); + +test("a receipt read just before midnight Pacific keeps its own calendar day", async () => { + // The end-to-end version of the above, through the worker. + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, date: "2026-08-03" } } as ReadOutcome), + companyTimeZone: async () => "America/Los_Angeles", + }); + await runIntakeWorker(h.deps); + assert.equal(h.applied[0].txnDate!.toISOString(), "2026-08-03T07:00:00.000Z"); +}); + +// ── Dedup ORDER: strong before weak (Codex round 3, item 1) ───────────────── + +test("an EXACT duplicate becomes DUPLICATE, not NEEDS_REVIEW", async () => { + // The regression this pins: an exact re-send matches BOTH nets. The weak + // lookup used to run first, so it routed on the weak hit and the strong + // claim — the only net that can answer DUPLICATE on its own — was never + // attempted. The one case the strong key exists to resolve automatically + // was the one case it never saw, and every re-sent receipt hit a human. + const order: string[] = []; + const h = harness([workerRow()], { + applyRead: async (_id, patch) => { + order.push("strong-claim"); + h.applied.push(patch); + return { owned: true, strongOwner: { id: "row-owner", totalCents: 36498, canonicalVendor: "lowes" } }; + }, + findWeakHit: async () => { order.push("weak-lookup"); return { id: "row-owner" }; }, + }); + const summary = await runIntakeWorker(h.deps); + + assert.deepEqual(summary.byState, { DUPLICATE: 1 }); + assert.equal(h.states[0].state, "DUPLICATE"); + assert.deepEqual(order, ["strong-claim"], "the weak net is never consulted once the strong one answers"); +}); + +test("the strong claim is attempted with the key, before any weak lookup", async () => { + const order: string[] = []; + const h = harness([workerRow()], { + applyRead: async (_id, patch) => { order.push("strong-claim"); h.applied.push(patch); return { owned: true, strongOwner: null }; }, + findWeakHit: async () => { order.push("weak-lookup"); return null; }, + }); + await runIntakeWorker(h.deps); + assert.deepEqual(order, ["strong-claim", "weak-lookup"]); + assert.equal(h.applied[0].dedupStrongKey, "2026-08-03|82766", "the claim carries the key"); + // The claim writes the KEYS but leaves the row RECEIVED and holding its + // lease. READ is reached only by finishRouting, once every net has spoken. + assert.equal(h.applied[0].state, "RECEIVED"); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: null, taxWarning: null }]); +}); + +test("the lease is held through routing and released only at the end", async () => { + // Clearing it at claim time let an overlapping invocation reclaim a + // half-routed row and BOOK it, after which this invocation would regress it. + const h = harness([workerRow()]); + await runIntakeWorker(h.deps); + assert.equal(h.applied.length, 1); + assert.ok(!("nextRetryAt" in h.applied[0]), "applyRead must not touch the lease"); + assert.equal(h.finished.length, 1, "exactly one release, at the end"); +}); + +test("a weak lookup that THROWS leaves the row RECEIVED, retryable, never READ", async () => { + // READ is terminal for a dry-run row, so a row parked there without a weak + // check would sit for the whole shadow week while the daily comparison + // counted it as fully deduped — a silent false negative in the one report + // the cutover decision rests on. + const h = harness([workerRow({ attempts: 0 })], { + findWeakHit: async () => { throw new Error("connection reset"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { RETRY: 1 }); + assert.deepEqual(h.finished, [], "never published to READ"); + assert.equal(h.applied[0].state, "RECEIVED"); + assert.equal(h.retried[0].attempts, 1); +}); + +test("a weak-only hit still asks a human, and KEEPS the strong key", async () => { + // This row is the live owner of that date|ref. Releasing the key would let + // a third copy claim it and book while the pair is still unresolved. + const h = harness([workerRow()], { findWeakHit: async () => ({ id: "row-twin" }) }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); + assert.equal(h.states[0].reason, "weak-dup:row-twin"); + assert.equal(h.states[0].state, "NEEDS_REVIEW"); + // ...and it RELEASES the strong key: nothing was sent to QuickBooks, so the + // documented pre-send rule applies here like anywhere else. Holding it made + // a CORRECTED resend collide with a row that was never booked, leaving two + // rows in review and neither able to proceed. + assert.equal(h.states[0].patch?.dedupStrongKey, null); +}); + +test("a document-level gate short-circuits BOTH nets and claims no key", async () => { + for (const [read, reason] of [ + [{ ...goodRead.read, docType: "multi" }, "multi-doc"], + [{ ...goodRead.read, totalAmount: "0.00" }, "refund-or-zero"], + [{ ...goodRead.read, totalAmount: "-22.57" }, "refund-or-zero"], + ] as const) { + let weakCalls = 0; + const h = harness([workerRow()], { + read: async () => ({ ok: true, read } as ReadOutcome), + findWeakHit: async () => { weakCalls++; return { id: "row-twin" }; }, + }); + await runIntakeWorker(h.deps); + // The tax note rides along with whatever state routing picked — a + // document can be both a bad tax read and a refund. + assert.ok(h.states[0].reason?.startsWith(reason), `${reason}: ${h.states[0].reason}`); + assert.equal(h.states[0].patch?.dedupStrongKey, null, reason); + assert.equal(weakCalls, 0, `${reason}: dedup is not consulted at all`); + } +}); + +// ── OCR'd tax is a reading, not a fact (Phase 3 gate, item b) ─────────────── + +test("an implausible tax is DROPPED and noted, and the receipt still books", () => { + // A misread decimal ("$2.92" as "$292") or a grabbed subtotal posts real + // money to the reimbursable-sales-tax account and inflates a state filing. + // WA's highest combined rate is ~10.6%, so 12% is the sanity bound. + const r = (tax: number | null, total: number | null, docType = "receipt") => + validateTaxCents(tax, total, docType); + + assert.deepEqual(r(29_20, 36_498), { taxCents: 2920, implausible: false }); + // Exactly at the ceiling, rounded UP to the cent so a legitimate rounding + // artefact at the boundary is not rejected. + assert.deepEqual(r(1200, 10_000), { taxCents: 1200, implausible: false }); + assert.deepEqual(r(1201, 10_000), { taxCents: null, implausible: true }); + // The decimal-point misread. + assert.deepEqual(r(29_200, 36_498), { taxCents: null, implausible: true }); + // Tax at or above the total is a grabbed subtotal, not a tax figure. + assert.deepEqual(r(36_498, 36_498), { taxCents: null, implausible: true }); + assert.deepEqual(r(40_000, 36_498), { taxCents: null, implausible: true }); + // Absent or zero tax is normal, not implausible — most receipts here. + assert.deepEqual(r(null, 36_498), { taxCents: null, implausible: false }); + assert.deepEqual(r(0, 36_498), { taxCents: null, implausible: false }); + // A tax with no usable total cannot be judged, so it is not trusted. + assert.deepEqual(r(500, null), { taxCents: null, implausible: true }); + + // A handwritten check to a sub has no sales tax, full stop. Any figure the + // model produced is the wrong number off the cheque, and booking it would + // move real money into the reimbursable-sales-tax account for a payment + // that was never taxed. Even a "plausible" 8% is refused. + assert.deepEqual(r(2920, 36_498, "check"), { taxCents: null, implausible: true }); + assert.deepEqual(r(100, 120_000, "check"), { taxCents: null, implausible: true }); + // ...but a check with NO tax reading is perfectly normal. + assert.deepEqual(r(null, 120_000, "check"), { taxCents: null, implausible: false }); + + assert.equal(MAX_PLAUSIBLE_TAX_RATE, 0.12); +}); + +test("a plausible tax is stored and the row carries no note", async () => { + const h = harness([workerRow()]); + await runIntakeWorker(h.deps); + assert.equal(h.applied[0].taxCents, 2920, "29.20 of 364.98 is ~8%"); + assert.equal(h.applied[0].stateReason, null); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: null, taxWarning: null }]); +}); + +test("an implausible tax nulls taxCents, notes the row, and does NOT park it", async () => { + // The receipt is fine and its TOTAL is what the bank charge matches, so it + // must still book — as a single un-split line, exactly like a receipt whose + // tax line was never readable. + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, taxAmount: "292.00" } } as ReadOutcome), + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.applied[0].taxCents, null, "the bad reading is dropped, not booked"); + assert.equal(h.applied[0].totalCents, 36498, "the total is untouched"); + assert.deepEqual(summary.byState, { READ: 1 }, "READ, not NEEDS_REVIEW"); + assert.deepEqual(h.finished, [{ + id: "row-1", + claimToken: "claim-1", + stateReason: "tax-implausible", + // AND IN ITS OWN COLUMN. `stateReason` is a display copy that every + // deferred booking and every park overwrites; this one is durable. + taxWarning: "tax-implausible", + }]); +}); + +test("the tax note survives alongside a dedup reason", async () => { + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, taxAmount: "292.00" } } as ReadOutcome), + findWeakHit: async () => ({ id: "row-twin" }), + }); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].reason, "weak-dup:row-twin;tax-implausible"); +}); + +test("the row stores only the tax BOOKING accepted, never a rejected reading", async () => { + // taxCents feeds the sales-tax reports, so it must never show a figure that + // no Purchase ever carried. The stored value is read back out of the SAME + // buildGroups the booking step calls. + const h = harness([workerRow()], { + read: async () => ({ + ok: true, + read: { ...goodRead.read, docType: "check", checkNumber: "4178", taxAmount: "29.20" }, + } as ReadOutcome), + }); + await runIntakeWorker(h.deps); + // buildGroups refuses to split tax on a check, so nothing was accepted. + assert.equal(h.applied[0].taxCents, null); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: "tax-implausible", taxWarning: "tax-implausible" }]); +}); + +test("a check with no tax reading books clean, with no note", async () => { + const h = harness([workerRow()], { + read: async () => ({ + ok: true, + read: { ...goodRead.read, docType: "check", checkNumber: "4178", taxAmount: "" }, + } as ReadOutcome), + }); + await runIntakeWorker(h.deps); + assert.equal(h.applied[0].taxCents, null); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: null, taxWarning: null }]); +}); + +test("a tax equal to the total is refused end to end", async () => { + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, taxAmount: "364.98" } } as ReadOutcome), + }); + await runIntakeWorker(h.deps); + assert.equal(h.applied[0].taxCents, null); + assert.equal(h.applied[0].totalCents, 36498, "the total is untouched"); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: "tax-implausible", taxWarning: "tax-implausible" }]); +}); + +// ── Fail-closed classifier (round-5 item 4) ──────────────────────────────── + +test("a missing or unknown doc_type is NEVER treated as a receipt", async () => { + // The old default was "receipt", and any unrecognised string also slipped + // past the exact multi/non_receipt checks. A truncated response, a schema + // change, or a prompt-injected document that suppressed the field while + // supplying plausible amounts went straight at QuickBooks. + for (const docType of ["", "unknown", "invoice", "RECEIPT_PLEASE_BOOK", "non-receipt"]) { + const h = harness([workerRow()], { + read: async () => ({ + ok: true, + read: { ...goodRead.read, docType: normalizeDocType(docType) }, + } as ReadOutcome), + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }, JSON.stringify(docType)); + assert.equal(h.states[0].reason, "unknown-doc-type", JSON.stringify(docType)); + assert.equal(h.states[0].patch?.dedupStrongKey, null, "and it claims no key"); + } +}); + +test("normalizeDocType accepts exactly the four the prompt may return", () => { + for (const ok of ["receipt", "check", "multi", "non_receipt"]) { + assert.equal(normalizeDocType(ok), ok); + assert.equal(normalizeDocType(ok.toUpperCase()), ok, "case is normalised"); + } + for (const bad of [undefined, null, "", " ", "invoice", "reciept", 42, {}, ["receipt"]]) { + assert.equal(normalizeDocType(bad), "unknown", JSON.stringify(bad)); + } + // Surrounding whitespace is a formatting artefact, not a different answer. + assert.equal(normalizeDocType(" receipt "), "receipt"); +}); + +// ── Fallback date in the company zone (round-5 item 5) ───────────────────── + +test("an unreadable date falls back to the COMPANY's calendar day, not UTC's", async () => { + // 2026-08-04T02:00Z is still the EVENING OF THE 3RD in Pacific. The old + // toISOString().slice(0,10) gave "2026-08-04", which changed the receipt's + // date, its dedup key, and its reporting period. + const h = harness([workerRow({ createdAt: new Date("2026-08-04T02:00:00.000Z") })], { + read: async () => ({ ok: true, read: { ...goodRead.read, date: "" } } as ReadOutcome), + companyTimeZone: async () => "America/Los_Angeles", + }); + await runIntakeWorker(h.deps); + assert.equal(h.applied[0].dedupWeakKey, "lowes|2026-08-03|364.98|amt", "the KEY uses the local day"); + assert.equal(h.applied[0].txnDate!.toISOString(), "2026-08-03T07:00:00.000Z"); + // Still no strong key: a fallback date is our guess, not the document's. + assert.equal(h.applied[0].dedupStrongKey, null); +}); + +// ── The sweep lives inside the run's budget (round-5 item 7) ─────────────── + +test("INTERLEAVING: a job assigned after the claim is honoured, not parked NEEDS_JOB", async () => { + // The pass claims a row with no project, spends ~25s in the reader, and a + // finalize writes the project in the meantime. Routing on the value read at + // claim time would publish NEEDS_JOB for a receipt that HAS a job — and + // NEEDS_JOB is exactly where a human goes looking for that problem, so the + // row would sit in the one queue that means the opposite of its state. + const h = harness([workerRow({ projectId: null })], { + refreshProjectId: async () => "proj-late", + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { READ: 1 }, "routed, not parked"); + assert.deepEqual(h.states, [], "no NEEDS_JOB park was written"); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "claim-1", stateReason: null, taxWarning: null }]); +}); + +test("a row with no job at claim time AND none at routing time still parks", async () => { + // The control for the test above: the re-read is a re-read, not a way to + // pretend every row has a job. + const h = harness([workerRow({ projectId: null })], { refreshProjectId: async () => null }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_JOB: 1 }); +}); + +test("a failing re-read falls back to the claimed value instead of losing the row", async () => { + // The snapshot ALREADY names a job, so the fallback asserts something the + // row itself recorded and a late assignment can only have refined. The + // routing gate asks whether a job exists at all, so the stale answer and the + // fresh one agree — this one may stand. + const h = harness([workerRow({ projectId: "proj-1" })], { + refreshProjectId: async () => { throw new Error("pool exhausted"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { READ: 1 }); +}); + +test("RACE: a DB blip during the read must not park an assigned receipt NEEDS_JOB", async () => { + // The interleaving: the pass claims a row with no project and spends ~25s + // in the reader. A person assigns the job in that window, and the re-read + // that would have SEEN it throws (a pool timeout, a dropped connection). + // + // Swallowing the throw turned a transient fault into a routing decision: + // the fallback is the CLAIMED snapshot, which by definition predates the + // assignment, so it asserted "still unassigned" — exactly the fact the + // failed call was supposed to establish — and parked the receipt NEEDS_JOB + // for a job it already had. The person sees their own assignment ignored, + // and the row waits for a human nothing will summon. + const h = harness([workerRow({ projectId: null })], { + refreshProjectId: async () => { throw new Error("pool exhausted"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { RETRY: 1 }, "the normal retry path, not a verdict"); + assert.deepEqual(h.states, [], "nothing was parked"); + assert.equal(h.retried.length, 1, "with a backoff and an attempt spent"); + assert.equal(h.retried[0].attempts, 1); + assert.match(h.retried[0].reason, /project-refresh-unavailable/); +}); + +test("the control: a re-read that ANSWERS 'no job' still parks NEEDS_JOB", async () => { + // The fix must not turn every unassigned receipt into an infinite retry. + // An answered null is a decision; only a FAILED call is a transient. + const h = harness([workerRow({ projectId: null })], { refreshProjectId: async () => null }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_JOB: 1 }); + assert.deepEqual(h.retried, []); +}); + +test("the deadline starts at invocation entry, so a slow sweep cannot overrun it", async () => { + // The sweep downloads objects. Timing it OUT of the budget meant it could + // eat the platform timeout and the worker would still go on to start a 25s + // Gemini read and a QBO round trip. + const h = harness([workerRow(), workerRow({ id: "row-2" })], { + sweepStaleStaging: async shouldStop => { + h.sweepCalls++; + assert.equal(typeof shouldStop, "function", "the sweep is given the deadline"); + assert.equal(shouldStop(), false, "not yet out of time"); + h.clock += RUN_SOFT_DEADLINE_MS + 1_000; // a slow sweep + assert.equal(shouldStop(), true, "the sweep can see it is out of time"); + return 1; + }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.reads, 0, "no Gemini call after the budget is gone"); + assert.equal(summary.processed, 0); + assert.equal(summary.deferredToNextRun, 2, "neither row was reached"); + // A deadline BEFORE the first row releases the whole batch: not one of + // them was looked at, so all ten minutes of their lease would otherwise be + // spent on rows nothing ever considered. + assert.deepEqual(h.releasedUnprocessed.map(r => r.id), ["row-1", "row-2"]); + assert.equal(summary.releasedUnprocessed, 2); +}); + +// ── A missing boundary halts the WHOLE pass (round-7 item 3) ─────────────── + +test("live mode with no recorded boundary claims nothing at all", async () => { + // Refusing only the retire/requeue was not enough: the pass went on to + // claim and BOOK rows while the shadow backlog sat undecided. Live mode + // without a boundary means we cannot tell which rows v1 already booked, + // and booking anything under that uncertainty is the double-booking this + // whole mechanism exists to prevent. + const h = harness([workerRow(), workerRow({ id: "row-2" })], { + isDryRunEnabled: () => false, + cutoverBoundary: async () => null, + }); + const summary = await runIntakeWorker(h.deps); + + assert.deepEqual(summary, { processed: 0, byState: {}, cutoverBlocked: "cutover-boundary-missing" }); + assert.deepEqual(h.claimOpts, [], "claim() is never even called"); + assert.equal(h.sweepCalls, 0, "and no housekeeping runs either"); + assert.equal(h.books, 0); + assert.equal(h.reads, 0); +}); + +test("dry-run mode does not need a boundary", async () => { + // Nothing books in shadow mode, so there is nothing to be uncertain about. + const h = harness([workerRow()], { isDryRunEnabled: () => true, cutoverBoundary: async () => null }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.cutoverBlocked, undefined); + assert.equal(summary.processed, 1); +}); + +// ── Orphaned objects are chased (round-7 item 5) ─────────────────────────── + +test("every pass retries storage deletes that failed earlier", async () => { + // A rejected row is deleted, so after that nothing in the database + // references its bytes — without this they sit in a private bucket forever. + const h = harness([], { retryStorageCleanups: async () => { h.cleanupCalls++; return 3; } }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.cleanupCalls, 1); + assert.equal(summary.orphansCleaned, 3); +}); + +test("a failing cleanup pass never takes the run down", async () => { + const h = harness([workerRow()], { + retryStorageCleanups: async () => { throw new Error("storage down"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.orphansCleaned, undefined); + assert.deepEqual(summary.byState, { READ: 1 }, "the batch still ran"); +}); + +// ── A row that never sent releases its key, whatever killed it (item 7) ──── + +test("a weak-lookup failure at the retry limit RELEASES the strong key", async () => { + // This row exhausted its attempts entirely on a database fault and never + // touched QuickBooks. Holding its key quarantines the corrected resend + // against a row that never became a purchase. + const h = harness([workerRow({ attempts: 19, sendAttempted: false })], { + findWeakHit: async () => { throw new Error("connection reset"); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); + assert.equal(h.states[0].reason, "max-retries"); + assert.equal(h.states[0].patch?.dedupStrongKey, null, "the key goes back"); +}); + +test("a finishRouting failure at the retry limit also releases the key", async () => { + const h = harness([workerRow({ attempts: 19, sendAttempted: false })], { + finishRouting: async () => { throw new Error("connection reset"); }, + }); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].reason, "max-retries"); + assert.equal(h.states[0].patch?.dedupStrongKey, null); +}); + +test("a row that DID send keeps its key at the retry limit", async () => { + // QuickBooks may hold a Purchase whose response we lost. + const h = harness([workerRow({ attempts: 19, sendAttempted: true })], { + findWeakHit: async () => { throw new Error("connection reset"); }, + }); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].reason, "max-retries"); + // parkTerminal always sends a patch; what matters is that it does NOT carry + // a key release for a row that reached QuickBooks. + assert.ok(!("dedupStrongKey" in (h.states[0].patch ?? {})), "the key is untouched"); +}); + +// ── Content changed under us (round-8 item 2) ────────────────────────────── + +test("a read whose bytes no longer match the recorded sha is TERMINAL", async () => { + // Sealing makes this nearly impossible; the check exists because "nearly" + // is not a guarantee, and reading whatever happens to be at a path is how a + // receipt for one job ends up booked against another. + const h = harness([workerRow()], { + downloadBytes: async () => ({ ok: false as const, kind: "sha-mismatch" as const, message: "x" }), + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { NEEDS_REVIEW: 1 }); + assert.equal(h.states[0].reason, "content-changed"); + assert.equal(h.reads, 0, "the model never sees bytes we cannot vouch for"); +}); + +test("the recorded sha is what the download is checked against", async () => { + const asked: Array<[string, string]> = []; + const h = harness([workerRow({ fileSha256: "abc".padEnd(64, "0") })], { + downloadBytes: async (p, sha) => { + asked.push([p, sha]); + return { ok: true as const, bytes: Buffer.from("bytes") }; + }, + }); + await runIntakeWorker(h.deps); + assert.deepEqual(asked, [["receipts/intake/row-1.jpg", "abc".padEnd(64, "0")]]); +}); + +// ── SHADOW_QUARANTINE (round-8 item 1) ───────────────────────────────────── + +test("the cutover reports quarantined rows separately from retired and requeued", async () => { + // Three outcomes, because "we cannot tell" is a real answer and collapsing + // it into either of the other two either double-books or loses an expense. + const h = harness([], { + isDryRunEnabled: () => false, + claim: async opts => { + h.claimOpts.push(opts); + return { rows: [], shadowRetired: 4, requeued: 2, shadowQuarantined: 3, shadowSkippedMoved: 0 }; + }, + }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.shadowRetired, 4); + assert.equal(summary.requeued, 2); + assert.equal(summary.shadowQuarantined, 3); +}); + +// ── The claim token fences the completing write (Phase 2 gate, a) ────────── + +test("finishRouting is handed the token the pass claimed with", async () => { + // A zombie worker resuming after its row was re-claimed must write nothing. + // The adapter matches on this token; the worker's job is to pass the one it + // actually holds. + const h = harness([workerRow({ claimToken: "token-abc" })]); + await runIntakeWorker(h.deps); + assert.deepEqual(h.finished, [{ id: "row-1", claimToken: "token-abc", stateReason: null, taxWarning: null }]); +}); + +// ── A successor reclaiming mid-flight (Phase 2 gate) ─────────────────────── + +test("a predecessor superseded before promotion writes nothing and books nothing", async () => { + const h = harness([workerRow({ state: "READ", dryRun: false, claimToken: "old-token" })], { + isDryRunEnabled: () => false, + // The CAS finds no row at {id, state: READ, claimToken: old-token} + // because the successor re-claimed and re-stamped it. + promoteToBooking: async (id, _weak, token) => { + h.promoted.push(id); + assert.equal(token, "old-token", "the predecessor offers its OWN token"); + return { promoted: false, stale: true }; + }, + }); + const summary = await runIntakeWorker(h.deps); + + assert.deepEqual(summary.byState, { STALE: 1 }); + assert.equal(h.books, 0, "no QBO call"); + assert.deepEqual(h.states, [], "no state write"); +}); + +test("a stale booking result is never written back", async () => { + const applied: unknown[] = []; + const h = harness([workerRow({ state: "BOOKING", dryRun: false })], { + isDryRunEnabled: () => false, + book: async () => { h.books++; return { outcome: "stale" } as BookResult; }, + applyBookResult: async (_id, result) => { applied.push(result); }, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { STALE: 1 }); + // applyBookResult is still CALLED — the adapter is what refuses to write — + // and the production adapter returns early on a stale outcome. + assert.deepEqual(applied, [{ outcome: "stale" }]); +}); + +test("every book result carries the row's claim token to the writer", async () => { + const tokens: Array = []; + const h = harness([workerRow({ state: "BOOKING", dryRun: false, claimToken: "tok-9" })], { + isDryRunEnabled: () => false, + applyBookResult: async (_id, _result, token) => { tokens.push(token); }, + }); + await runIntakeWorker(h.deps); + assert.deepEqual(tokens, ["tok-9"]); +}); + +// ── Ownership is CAS'd on EVERY mutation (round-10 item 3) ───────────────── + +test("losing the row aborts each mutation path instead of clobbering a successor", async () => { + // A zombie worker holds a view its successor has already moved past. Every + // write it attempts must affect zero rows and stop the pass for that row — + // a time-based lease cannot express this, because both hold the same id. + const lost = { owned: false as const }; + + // applyState at the document-level gate (a terminal outcome, so it is the + // releasing write that carries it, not applyRead). + const gate = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, docType: "multi" } }) as ReadOutcome, + applyState: async () => false, + }); + assert.deepEqual((await runIntakeWorker(gate.deps)).byState, { STALE: 1 }); + + // applyRead at the strong claim. + const claim = harness([workerRow()], { applyRead: async () => ({ ...lost, strongOwner: null }) }); + assert.deepEqual((await runIntakeWorker(claim.deps)).byState, { STALE: 1 }); + assert.deepEqual(claim.finished, [], "never published"); + + // applyState, via a terminal park. + const park = harness([workerRow()], { + downloadBytes: async () => ({ ok: false as const, kind: "missing" as const }), + applyState: async () => false, + }); + assert.deepEqual((await runIntakeWorker(park.deps)).byState, { STALE: 1 }); + + // deferRead, via an AI outage. + const defer = harness([workerRow()], { + read: async () => ({ ok: false, decisive: false }), + deferRead: async () => false, + }); + assert.deepEqual((await runIntakeWorker(defer.deps)).byState, { STALE: 1 }); + + // retryRow, via a transient storage fault. + const retry = harness([workerRow()], { + downloadBytes: async () => ({ ok: false as const, kind: "transient" as const, message: "x" }), + retryRow: async () => false, + }); + assert.deepEqual((await runIntakeWorker(retry.deps)).byState, { STALE: 1 }); +}); + +test("every mutation is offered the row's OWN state and token", async () => { + const seen: unknown[] = []; + const h = harness([workerRow({ claimToken: "tok-7" })], { + applyRead: async (_id, patch, ownership) => { + seen.push(ownership); + h.applied.push(patch); + return { owned: true, strongOwner: null }; + }, + }); + await runIntakeWorker(h.deps); + assert.deepEqual(seen, [{ state: "RECEIVED", claimToken: "tok-7" }]); +}); + +// ── One parkTerminal decides the key release (round-10 item 4) ───────────── + +test("EVERY pre-send terminal park releases the strong key", async () => { + // Each of these used to decide independently, and the ones that forgot held + // a dedup key against a Purchase that never existed — so the corrected + // resubmission collided with nothing. + const cases: Array<[string, Partial]> = [ + ["file-missing", { downloadBytes: async () => ({ ok: false as const, kind: "missing" as const }) }], + ["content-changed", { downloadBytes: async () => ({ ok: false as const, kind: "sha-mismatch" as const, message: "x" }) }], + ["unreadable", { read: async () => ({ ok: false, decisive: true }) }], + ]; + for (const [reason, over] of cases) { + const h = harness([workerRow({ sendAttempted: false })], over); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].reason, reason); + assert.equal(h.states[0].patch?.dedupStrongKey, null, `${reason} must release the key`); + } + + // ...and the AI-unavailable ceiling, which is a different code path again. + const busy = harness([workerRow({ sendAttempted: false, busyPasses: MAX_BUSY_PASSES - 1 })], { + read: async () => ({ ok: false, decisive: false }), + }); + await runIntakeWorker(busy.deps); + assert.equal(busy.states[0].reason, "ai-unavailable"); + assert.equal(busy.states[0].patch?.dedupStrongKey, null); +}); + +test("a park AFTER a send keeps the key, on every one of those paths", async () => { + for (const over of [ + { downloadBytes: async () => ({ ok: false as const, kind: "missing" as const }) }, + { read: async () => ({ ok: false as const, decisive: true }) }, + ]) { + const h = harness([workerRow({ sendAttempted: true })], over); + await runIntakeWorker(h.deps); + assert.ok(!("dedupStrongKey" in (h.states[0].patch ?? {})), "the Purchase may exist"); + } +}); + +// ── The upload lease, not the row's age (round-13 item 2) ────────────────── + +test("a re-issued upload URL keeps the row safe from the sweeper", () => { + // The row is old; its LEASE is not. Judging it on createdAt declared a + // receipt missing — or destroyed one it called unacceptable — while the + // client's own upload link was live and about to land. + const old = new Date(NOW.getTime() - 6 * 60 * 60_000); + assert.equal( + uploadLeaseActive({ createdAt: old, uploadUrlExpiresAt: new Date(NOW.getTime() + 60_000) }, NOW), + true, + "a fresh lease on an old row", + ); + assert.equal( + uploadLeaseActive({ createdAt: old, uploadUrlExpiresAt: new Date(NOW.getTime() - 60_000) }, NOW), + false, + "an expired lease is expired, however recently the row was touched", + ); + // A row with no lease at all (the single-shot path writes its bytes through + // the server) falls back to its own age. + assert.equal(uploadLeaseActive({ createdAt: old, uploadUrlExpiresAt: null }, NOW), false); + assert.equal( + uploadLeaseActive({ createdAt: new Date(NOW.getTime() - 60_000), uploadUrlExpiresAt: null }, NOW), + true, + ); +}); + +test("the lease a URL is issued under is exactly the signed-URL TTL", () => { + assert.equal(uploadLeaseExpiry(NOW).getTime() - NOW.getTime(), SIGNED_UPLOAD_TTL_MS); + assert.equal(SIGNED_UPLOAD_TTL_MS, 2 * 60 * 60_000); +}); + +// ── A late read gets what's left, not a fresh 25s (Codex round-17 item 2) ── + +test("a read starting early in the run gets its full budget", () => { + // Plenty of runway left: capped at READ_BUDGET_MS, never handed more. + assert.equal(readBudgetFor(50_000), READ_BUDGET_MS); +}); + +test("a read starting late in the run gets only what's left, minus the safety margin", () => { + // 10s left in the whole invocation must not become a fresh 25s read that + // can straddle the platform's own ceiling — it gets 10s minus the margin + // reserved for writing the result back. + assert.equal(readBudgetFor(10_000), 10_000 - READ_SAFETY_MARGIN_MS); +}); + +test("too little runway skips the read entirely rather than starting a doomed one", () => { + // Exactly at the floor once the margin is reserved: still worth trying. + assert.equal(readBudgetFor(READ_MIN_BUDGET_MS + READ_SAFETY_MARGIN_MS), READ_MIN_BUDGET_MS); + // Under the floor: 0, meaning "don't even try" — the same AI_UNAVAILABLE + // answer as an exhausted budget, so the row costs no `attempts` and comes + // back next pass with a full budget again. + assert.equal(readBudgetFor(READ_MIN_BUDGET_MS + READ_SAFETY_MARGIN_MS - 1), 0); + assert.equal(readBudgetFor(1_000), 0); + assert.equal(readBudgetFor(0), 0); + assert.equal(readBudgetFor(-5_000), 0); +}); + +test("/start stamps a lease on every url it issues, including a live-lease retry", () => { + const start = readFileSync( + path.join(__dirname, "..", "src/app/api/receipts/intake/start/route.ts"), + "utf8", + ); + // Four branches, four lease stamps: the new row, the re-armed park, the + // resumed STAGING upload, AND a retry against a still-live lease. A URL + // handed out without a lease extension is one the sweeper cannot see coming + // — a resigned URL for an unexpired lease is good for a fresh ~2h window, + // so leaving the row's recorded expiry at its OLD value let the sweeper + // judge the lease dead while the client still held a perfectly live URL. + // + // Three of them are here; the fourth is the shared live-lease rule, which + // now serves BOTH resumable states from one place (upload-lease.ts) and + // takes the same clock as an injected dependency. + // The create branch holds its stamp in a const, because the signer-failure + // discard CASes on that EXACT value and a second uploadLeaseExpiry() call + // would compare a fresh instant against the stored one; the other two stamp + // inline. + assert.match(start, /const leaseExpiresAt = uploadLeaseExpiry\(\);/); + assert.match(start, /uploadUrlExpiresAt: leaseExpiresAt,/, "the new row still gets a lease"); + assert.equal( + (start.match(/uploadUrlExpiresAt: uploadLeaseExpiry\(\)/g) ?? []).length, + 2, + "re-arm and resume stamp the lease inline", + ); + assert.match(start, /expiresAt: uploadLeaseExpiry,/, "and the shared rule is given the same clock"); + const lease = readFileSync( + path.join(__dirname, "..", "src/lib/receipt-intake/upload-lease.ts"), + "utf8", + ); + assert.match( + lease, + // Through extendedExpiry, which forces the written instant PAST the + // one it found: an extension moves nothing else, so the expiry is + // the only witness the signer-failure discard has. + /uploadUrlExpiresAt: extendedExpiry\(observed\.uploadUrlExpiresAt, deps\.expiresAt\(\)\),/, + "the shared rule stamps it too", + ); + // And the ADOPTION GENERATION alongside it, on every one of the four. The + // expiry alone cannot identify a lease -- a reuse writes the same "now + 2h" + // the original issue did, so the discard CAS pins this instead. + // Hoisted now, because /finalize requires the generation its URL was + // issued under and the caller has to hand it back — so the value written + // to the row and the value returned to the client must be the SAME draw, + // not two calls to the generator. + // AN EXTENSION KEEPS the generation it adopted -- see the round-19 note + // in upload-lease.ts. Only a row that never had one (a legacy row, null) + // draws a fresh value, and the CAS pins the null so exactly one writer + // mints it. + assert.match(lease, /const uploadLease = observed\.uploadLeaseNonce \?\? \(deps\.nonce \?\? newLeaseNonce\)\(\);/); + assert.match(lease, /uploadLeaseNonce: uploadLease,/); + assert.match(lease, /signed: \{ \.\.\.signed, uploadLease \}/); + // Both destructive branches still stamp a FRESH generation — hoisted into + // a const now, for the same reason as the reuse path: /finalize requires + // the generation, so the response has to echo the value that was written. + assert.match(start, /const rearmedLease = newLeaseNonce\(\);/); + assert.match(start, /const resumedLease = newLeaseNonce\(\);/); + assert.equal( + (start.match(/uploadLeaseNonce: (rearmedLease|resumedLease),/g) ?? []).length, + 2, + "the re-arm and the resume each write the generation they minted", + ); + assert.equal( + (start.match(/uploadLease: (rearmedLease|resumedLease),/g) ?? []).length, + 2, + "...and each hands that same value back", + ); + assert.equal( + (start.match(/uploadLeaseNonce: leaseNonce/g) ?? []).length, + 2, + "and the create holds ITS generation in a const, because the discard CAS pins that exact value", + ); + const signed = (start.match(/await signUpload\(/g) ?? []).length; + assert.equal(signed, 3, "one signUpload call per inline branch"); + // ...and it is the ONE issuer that asks for an upsert-capable token, because + // it re-signs an EXISTING path so a client can replace its own partial + // upload. Every other issuer signs a path a version bump has just made new. + assert.match( + lease, + /await deps\.sign\(path, \{ upsert: true \}\)/, + "the shared rule signs the path it kept, with the overwrite capability it needs", + ); + // The liveness test is its OWN predicate now, because two different + // answers used to collapse into liveLeasePath's null: "nothing live here, + // take a new lease" and "there IS a live lease, but for a different file + // type". The second is a refusal -- repathing it orphans an object whose + // URL is still in somebody's hands. + assert.match( + lease, + /export function hasLiveLease\(row: LeaseRow, now: number = Date\.now\(\)\): boolean \{/, + "the live-lease retry is gated on the lease still being live", + ); + assert.match( + lease, + /return !!row\.uploadUrlExpiresAt && row\.uploadUrlExpiresAt\.getTime\(\) > now;/, + "and the gate is an expiry comparison, not a proxy for one", + ); + assert.match( + lease, + /if \(hasLiveLease\(observed, at\)\) \{[\s\S]{0,300}?kind: \"identity-conflict\"/, + "a live lease this request disagrees with is refused, never repathed", + ); +}); + +// ── A finished row hands the claim back, whatever finished it ───────────── + +test("EVERY early terminal outcome releases the claim in the same write", async () => { + // The hole: these four were written by applyRead, which deliberately KEEPS + // the lease because routing normally continues under it. For an outcome + // that ends the row there is no "afterwards" — so the row sat finished and + // still owned, which the health probe reads as claimed and every fenced + // write misses. + const outcomes: Array<[string, Partial, string]> = [ + ["multi-document", { + read: async () => ({ ...goodRead, read: { ...goodRead.read, docType: "multi" } }) as ReadOutcome, + }, "NEEDS_REVIEW"], + ["non-receipt", { + read: async () => ({ ...goodRead, read: { ...goodRead.read, docType: "non_receipt" } }) as ReadOutcome, + }, "NON_RECEIPT"], + ["zero or refund", { + read: async () => ({ ...goodRead, read: { ...goodRead.read, totalAmount: "0.00" } }) as ReadOutcome, + }, "NEEDS_REVIEW"], + ]; + for (const [label, overrides, expected] of outcomes) { + const h = harness([workerRow()], overrides); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { [expected]: 1 }, label); + assert.deepEqual(h.applied, [], `${label}: nothing kept the lease`); + assert.equal(h.states.length, 1, label); + // Fenced on the row's OWN state and token — which is what makes the + // release atomic with the transition rather than a second write. + assert.deepEqual(h.states[0].ownership, { state: "RECEIVED", claimToken: "claim-1" }, label); + assert.deepEqual(h.finished, [], `${label}: finishRouting is for READ only`); + } + + // The no-job park takes the same road. + const noJob = harness([workerRow({ projectId: null })], { refreshProjectId: async () => null }); + assert.deepEqual((await runIntakeWorker(noJob.deps)).byState, { NEEDS_JOB: 1 }); + assert.deepEqual(noJob.applied, [], "no-project is terminal too"); + assert.deepEqual(noJob.states[0].ownership, { state: "RECEIVED", claimToken: "claim-1" }); +}); + +test("a terminal write that LOSES its fence reports STALE and nothing else", async () => { + const h = harness([workerRow()], { + read: async () => ({ ...goodRead, read: { ...goodRead.read, docType: "multi" } }) as ReadOutcome, + applyState: async () => false, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { STALE: 1 }); + assert.deepEqual(h.finished, []); +}); + +test("the ONE write that keeps the lease can only ever say RECEIVED", () => { + // Enforced by the type (`patch: ReadPatch & { state: "RECEIVED" }`), so a + // terminal state cannot be routed back through applyRead by accident. This + // asserts the contract is still written down where the compiler reads it. + const worker = readFileSync( + path.join(__dirname, "..", "src/lib/receipt-intake/worker.ts"), + "utf8", + ); + assert.match(worker, /patch: ReadPatch & \{ state: "RECEIVED" \}/); + assert.match(worker, /THE ONE WRITE THAT KEEPS THE CLAIM/); +}); + +// ── A park after a send must never hand the key back (round-14 A) ────────── + +test("a park decided AFTER a send reads the PERSISTED flag, not the claim snapshot", async () => { + // The hole: everything after the QBO create — the post-create phase check, + // the Expense commit, a pool timeout — could throw out to the worker's + // generic handler, which parked the row from the snapshot it claimed with. + // That snapshot says "nothing sent", so the dedup key went back for a row + // with a Purchase in the real books, and the next submission of the same + // receipt booked it a second time. + const h = harness([workerRow({ state: "READ", dryRun: false, sendAttempted: false, attempts: 19 })], { + isDryRunEnabled: () => false, + book: async () => { throw new Error("connection reset after the create"); }, + }); + h.persistedSendAttempted = true; // markSendAttempted got there first + await runIntakeWorker(h.deps); + + assert.deepEqual(h.sendReads, ["row-1"], "the flag was re-read"); + assert.equal(h.states.length, 1); + assert.equal(h.states[0].state, "NEEDS_REVIEW"); + assert.ok( + !("dedupStrongKey" in (h.states[0].patch ?? {})), + "the key is RETAINED: a Purchase may exist", + ); +}); + +test("a park with nothing ever sent still releases the key", async () => { + // The control. Holding a key against a booking that never happened sends + // the corrected resubmission to a human for no reason. + const h = harness([workerRow({ state: "READ", dryRun: false, sendAttempted: false, attempts: 19 })], { + isDryRunEnabled: () => false, + book: async () => { throw new Error("connection reset"); }, + }); + h.persistedSendAttempted = false; + await runIntakeWorker(h.deps); + assert.equal(h.states[0].patch?.dedupStrongKey, null); +}); + +test("an unreadable send flag RETAINS the key", async () => { + // Retaining costs a review item; releasing wrongly costs a second Purchase. + const h = harness([workerRow({ state: "READ", dryRun: false, sendAttempted: false, attempts: 19 })], { + isDryRunEnabled: () => false, + book: async () => { throw new Error("boom"); }, + sendAttemptedNow: async () => { throw new Error("db is down"); }, + }); + await runIntakeWorker(h.deps); + assert.ok(!("dedupStrongKey" in (h.states[0].patch ?? {}))); +}); + +// ── An inline STAGING orphan is not waiting for a URL (round-15 item 3) ──── + +test("a row that never had a signed URL gets the SWEEP threshold, not the URL TTL", () => { + // The single-shot path writes its bytes through the server inside one + // request: such a row is either published or it failed mid-request. Giving + // it the two-hour signed-URL grace made every inline orphan invisible to the + // sweep for two hours, waiting on a URL that does not exist. + const inlineAge = (minutes: number) => ({ + uploadUrlExpiresAt: null, + createdAt: new Date(NOW.getTime() - minutes * 60_000), + }); + assert.equal(uploadLeaseActive(inlineAge(5), NOW), true, "still inside the sweep threshold"); + assert.equal(uploadLeaseActive(inlineAge(20), NOW), false, "past it — an orphan now, not in 2 hours"); + assert.equal(uploadLeaseActive(inlineAge(90), NOW), false); + + // A two-step row is still judged by the promise /start actually made. + assert.equal( + uploadLeaseActive({ + uploadUrlExpiresAt: new Date(NOW.getTime() + 60_000), + createdAt: new Date(NOW.getTime() - 90 * 60_000), + }, NOW), + true, + "an old row with a live lease is still uploading", + ); +}); + +test("the sweep query excludes live leases and orders null-lease rows first", () => { + const sweeper = readFileSync( + path.join(__dirname, "..", "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", + ); + const fn = sweeper.slice(sweeper.indexOf("sweepStaleStaging: async")); + const query = fn.slice(0, fn.indexOf("let published")); + // Filtered in SQL, not skipped in the loop: a handful of clients still + // uploading could otherwise fill all ten slots every pass, so the orphans + // behind them were never reached. + assert.match(query, /uploadUrlExpiresAt: null/); + assert.match(query, /uploadUrlExpiresAt: \{ lte: sweptAt \}/); + assert.match(query, /orderBy: \[/); + assert.match(query, /\{ uploadUrlExpiresAt: \{ sort: "asc", nulls: "first" \} \}/); + assert.match(query, /\{ createdAt: "asc" \}/); + assert.match(query, /take: STAGING_SWEEP_BATCH/); +}); + +// ── Dry-run ROLLBACK starvation (Codex a2998e8a, finding 1) ────────────────── +// +// The hole the last round left: booking learned to honour the CURRENT global +// switch, but claim ELIGIBILITY still only excluded rows whose PERSISTED +// dryRun was true. Flip RECEIPT_INTAKE_DRYRUN back on after a live window and +// every row claimed during that window is still `dryRun:false`, still sitting +// in READ/BOOKING, and still claimable — so each pass filled its ten-row batch +// with rows it then refused to advance (without even releasing the claim), and +// the newer RECEIVED receipts behind them were never read. + +test("claimable states are a function of the CURRENT switch, not the row flag", () => { + assert.deepEqual( + claimableStates(true), + ["RECEIVED"], + "under dry-run nothing whose next step is a QBO write may be claimed", + ); + assert.deepEqual(claimableStates(false), ["RECEIVED", "READ", "BOOKING"]); + // The two lists differ by exactly the QBO-writing states — spelled out so a + // future state added to one list cannot silently skip the other. + assert.deepEqual([...QBO_WRITING_STATES], ["READ", "BOOKING"]); +}); + +test("the claim predicate drops the QBO-writing states while dry-run is on", () => { + const now = new Date("2026-09-01T12:00:00.000Z"); + + const dry = eligibleClaimWhere(now, true) as Record; + assert.deepEqual(dry.state, { in: ["RECEIVED"] }); + + const live = eligibleClaimWhere(now, false) as Record; + assert.deepEqual(live.state, { in: ["RECEIVED", "READ", "BOOKING"] }); + // The shadow-week park exclusion survives the change: a dryRun=true row at + // READ/BOOKING is still off the list on a LIVE pass until the cutover + // requeues it. + assert.deepEqual(live.NOT, { AND: [{ dryRun: true }, { state: { in: ["READ", "BOOKING"] } }] }); + // And the retry clause is untouched by any of it. + assert.deepEqual(live.OR, [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]); +}); + +/** + * A queue with more than two full batches of OLD rows left live by a previous + * window, plus newer RECEIVED receipts behind them. + * + * The fake claim is deliberately built on the SHIPPED `claimableStates` rather + * than a hand-written state list, so this test measures the real predicate. The + * `states` override is what lets the same fixture reproduce the BUG (the old + * predicate, which ignored the switch) as a control. + */ +function starvationQueue(opts: { states?: (dryRunGlobal: boolean) => string[] } = {}) { + const pickStates = opts.states ?? claimableStates; + const rows: WorkerRow[] = []; + // 25 old rows — two and a half batches — left at READ with dryRun=false by + // a live window that has since been rolled back. + for (let i = 0; i < 25; i++) { + rows.push(workerRow({ + id: "old-" + i, + sourceRef: "drive:OLD" + i, + state: "READ", + dryRun: false, + createdAt: new Date(Date.parse("2026-08-20T00:00:00.000Z") + i * 60_000), + })); + } + // Three receipts that arrived AFTER the rollback. These are the ones the + // shadow week is supposed to keep reading. + for (let i = 0; i < 3; i++) { + rows.push(workerRow({ + id: "new-" + i, + sourceRef: "drive:NEW" + i, + state: "RECEIVED", + dryRun: true, + createdAt: new Date(Date.parse("2026-08-30T00:00:00.000Z") + i * 60_000), + })); + } + + const nextRetryAt = new Map(); + let clock = Date.parse("2026-09-01T12:00:00.000Z"); + + return { + rows, + advanceMinutes(mins: number) { clock += mins * 60_000; }, + /** The route's claim, in memory: same predicate, same oldest-first order, same lease. */ + claim: async (o: CutoverRequest) => { + const eligible = new Set(pickStates(o.dryRunGlobal)); + const due = rows + .filter(r => eligible.has(r.state)) + .filter(r => (nextRetryAt.get(r.id) ?? 0) <= clock) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, BATCH_SIZE); + // The claim bumps every taken row's nextRetryAt by the lease. + for (const r of due) nextRetryAt.set(r.id, clock + 10 * 60_000); + return { rows: due, shadowRetired: 0, requeued: 0, shadowQuarantined: 0, shadowSkippedMoved: 0 }; + }, + /** What the worker's own release writes back. */ + release: async (id: string, when: Date) => { nextRetryAt.set(id, when.getTime()); return true; }, + }; +} + +test("ROLLBACK: newer receipts are read on the FIRST pass, not starved behind the old backlog", async () => { + const q = starvationQueue(); + const readIds: string[] = []; + const h = harness(q.rows, { + isDryRunEnabled: () => true, + claim: q.claim, + releaseClaim: q.release, + }); + // Record which rows actually reach the reader. + h.deps.applyRead = async (id, patch) => { + readIds.push(id); + h.applied.push(patch as ReadPatch); + return { owned: true, strongOwner: null }; + }; + + const summary = await runIntakeWorker(h.deps); + + assert.deepEqual( + readIds.slice().sort(), + ["new-0", "new-1", "new-2"], + "all three post-rollback receipts are read in the first invocation", + ); + assert.equal(summary.processed, 3, "the old live rows never even occupy a batch slot"); + assert.equal(h.books, 0, "and nothing books while the switch says dry-run"); +}); + +test("ROLLBACK control: the OLD predicate really did starve them (two full batches deep)", async () => { + // Without this control the test above would pass against a queue that + // simply had no old rows in it. Here the ONLY difference is the predicate: + // the pre-fix one, which looked at the persisted flag and ignored the + // switch. Two invocations is already enough to prove the starvation. + const q = starvationQueue({ states: () => ["RECEIVED", "READ", "BOOKING"] }); + const readIds: string[] = []; + const h = harness(q.rows, { + isDryRunEnabled: () => true, + claim: q.claim, + // The pre-fix loop skipped without releasing, so the rows kept the + // full ten-minute lease. + releaseClaim: async () => true, + }); + h.deps.applyRead = async (id, patch) => { + readIds.push(id); + h.applied.push(patch as ReadPatch); + return { owned: true, strongOwner: null }; + }; + + await runIntakeWorker(h.deps); + q.advanceMinutes(5); + await runIntakeWorker(h.deps); + + assert.deepEqual(readIds, [], "twenty old rows fill both batches and no new receipt is reached"); +}); + +test("ROLLBACK is not a black hole: going live again makes the old rows claimable", async () => { + // Excluding a row from the claim must not strand it. The predicate is + // evaluated per invocation from the current switch, so the same rows come + // straight back the moment the switch flips. + const q = starvationQueue(); + const h = harness(q.rows, { isDryRunEnabled: () => false, claim: q.claim, releaseClaim: q.release }); + const summary = await runIntakeWorker(h.deps); + assert.equal(summary.processed, BATCH_SIZE, "a live pass claims the old backlog oldest-first again"); + assert.equal(h.books, BATCH_SIZE, "and books it"); +}); + +test("a row the switch refuses RELEASES its claim instead of sitting on it", async () => { + // Belt-and-braces for the eligibility fix: if the switch is ever read as + // live at claim time and dry-run inside the loop, the skip must still hand + // the row back. A skip that kept the claim left the row owned by a pass + // that had finished — invisible to every fenced write until the lease + // lapsed, and back in the next batch to be skipped again. + for (const state of ["READ", "BOOKING"] as const) { + const h = harness([workerRow({ state, dryRun: false })], { isDryRunEnabled: () => true }); + const summary = await runIntakeWorker(h.deps); + assert.equal(h.books, 0); + assert.deepEqual(summary.byState, { [state]: 1 }, state + " is unchanged — nothing is decided"); + assert.equal(h.releasedClaims.length, 1, state + " hands the claim back"); + assert.equal( + h.releasedClaims[0].nextRetryAt.getTime(), + NOW.getTime() + DRYRUN_PARK_RETRY_MS, + "deferred by an hour, so it stops competing for batch slots with new receipts", + ); + } +}); + +test("a release that loses its fence reports STALE rather than claiming to have parked", async () => { + const h = harness([workerRow({ state: "READ", dryRun: false })], { + isDryRunEnabled: () => true, + releaseClaim: async () => false, + }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary.byState, { STALE: 1 }); +}); + +// ── Whole-pass overlap lease (Codex a2998e8a, finding 4) ───────────────────── + +test("a second invocation that cannot take the lease does NOTHING", async () => { + const h = harness([workerRow()], { acquireLease: async () => null }); + const summary = await runIntakeWorker(h.deps); + assert.deepEqual(summary, { processed: 0, byState: {}, skipped: "lease-held" }); + assert.equal(h.claimOpts.length, 0, "no claim"); + assert.equal(h.sweepCalls, 0, "no sweep"); + assert.equal(h.reads, 0, "no Gemini call"); + assert.equal(h.books, 0, "no QuickBooks call"); +}); + +test("the lease is released on a normal pass", async () => { + const h = harness([workerRow()]); + await runIntakeWorker(h.deps); + assert.equal(h.leaseAcquires, 1); + assert.equal(h.leaseReleases, 1); +}); + +test("the lease is released even when the pass throws", async () => { + // Row errors are caught per row, but a claim/sweep failure propagates. A + // lease leaked there would wedge the queue for a whole TTL. + const h = harness([], { claim: async () => { throw new Error("prisma exploded"); } }); + await assert.rejects(() => runIntakeWorker(h.deps), /prisma exploded/); + assert.equal(h.leaseReleases, 1); +}); + +// ── No storage call outlives its invocation (Codex round-16 item 1) ──────── +// +// Every bucket.ts function used to `await` Supabase with no timeout and no +// abort signal, and the worker's `shouldStop` only runs BETWEEN operations. So +// one hung request ate the whole 60-second lifetime: the platform killed the +// function mid-pass, the rows it had claimed never reached the release path, +// and they sat leased for ten minutes — and because the claim is oldest-first, +// the same object hung the next run too. + +test("the budget comes from the caller's deadline, and never exceeds the cap", () => { + // A call late in a pass gets what is actually LEFT, not a fresh fixed + // timeout that could straddle the platform ceiling. + const started = Date.now(); + assert.equal(storageBudgetMs(undefined), STORAGE_CALL_MAX_MS, "no deadline: the cap"); + assert.equal( + storageBudgetMs({ startedAt: started, budgetMs: 60_000 }), + STORAGE_CALL_MAX_MS, + "plenty left: still capped", + ); + const nearlyOut = storageBudgetMs({ startedAt: started - 57_000, budgetMs: 60_000 }); + assert.ok(nearlyOut > 0 && nearlyOut <= 3_100, `only what is left: ${nearlyOut}`); + assert.equal(storageBudgetMs({ startedAt: started - 61_000, budgetMs: 60_000 }), 0, "past it: none"); +}); + +/** + * A Supabase that never answers. `getSupabaseWithSignal` builds its client over + * the global fetch, so replacing that is what makes a genuinely hung request + * reachable from a unit test — no network, no timers but ours. + */ +async function withHungStorage(run: () => Promise): Promise<{ out: T; aborted: boolean; fetches: number }> { + const realFetch = globalThis.fetch; + const realUrl = process.env.SUPABASE_URL; + const realKey = process.env.SUPABASE_SERVICE_KEY; + let aborted = false; + let fetches = 0; + process.env.SUPABASE_URL = "https://storage.invalid"; + process.env.SUPABASE_SERVICE_KEY = "test-key"; + globalThis.fetch = ((_input: unknown, init?: { signal?: AbortSignal }) => { + fetches++; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }); + }); + }) as typeof fetch; + try { + return { out: await run(), aborted, fetches }; + } finally { + globalThis.fetch = realFetch; + if (realUrl === undefined) delete process.env.SUPABASE_URL; else process.env.SUPABASE_URL = realUrl; + if (realKey === undefined) delete process.env.SUPABASE_SERVICE_KEY; else process.env.SUPABASE_SERVICE_KEY = realKey; + } +} + +test("a NEVER-SETTLING storage request returns before the deadline, and is aborted", async () => { + // The failure, exactly: a request that never answers. Without the guard + // this await would still be pending when the platform killed the function, + // so the pass never reached the code that releases its claimed rows. + const started = Date.now(); + const { out, aborted } = await withHungStorage(() => + downloadReceiptObject("receipts/intake/hung.png", { startedAt: started, budgetMs: 1_200 })); + const elapsed = Date.now() - started; + + assert.equal(out.ok, false); + assert.equal((out as { kind: string }).kind, "transient", "retryable, never a verdict"); + assert.match(String((out as { message?: string }).message), /storage-timeout/); + assert.ok(elapsed < 5_000, `returned in ${elapsed}ms rather than hanging`); + // The socket goes with the promise: a timer that only settled the await + // would leave the request running against the next invocation's budget. + assert.equal(aborted, true, "the request was actually aborted"); +}); + +test("a call with no runway left never starts at all", async () => { + // Spending the pass's last milliseconds on a request whose answer it can + // never use is how the release path gets skipped. + const { out, fetches } = await withHungStorage(() => + downloadReceiptObject("receipts/intake/a.png", { + startedAt: Date.now() - 60_000, + budgetMs: 60_000, + })); + assert.equal(out.ok, false); + assert.match(String((out as { message?: string }).message), /storage-timeout/); + // THE DISTINGUISHING PROPERTY: no request was made at all. Without the + // runway check the call is issued with a zero-millisecond timer, which + // rejects with the same tag — so only the absence of the request tells the + // two apart, and the point is not to spend the last of the budget on an + // answer the pass can never use. + assert.equal(fetches, 0, "no storage request was issued"); +}); + +test("EVERY bucket export takes a deadline and runs under the guard", () => { + // The audit the finding asked for, as an assertion: a new storage call + // added without the guard is the same bug back. + const src = readFileSync(path.join(__dirname, "..", "src/lib/receipt-intake/bucket.ts"), "utf8"); + for (const op of ["list", "download", "upload", "remove", "sign-upload", "sign-download"]) { + assert.ok(src.includes(`withStorageDeadline("${op}"`), `${op} is guarded`); + } + // The unsignalled singleton is unreachable from this file, so nothing here + // CAN make an unbounded call. + assert.ok(!/getSupabase\(\)/.test(src), "the unsignalled client is not reachable"); + assert.match(src, /import \{ getSupabaseWithSignal \}/); + // ...and the guard aborts before it rejects, so the socket goes with it. + const guard = src.slice(src.indexOf("async function withStorageDeadline")); + const abortAt = guard.indexOf("controller.abort()"); + const rejectAt = guard.indexOf("reject(new StorageTimeoutError"); + assert.ok(abortAt > 0 && abortAt < rejectAt, "abort precedes the rejection"); +}); + +test("consecutive timeouts are counted, and the run resets on any other failure", () => { + // The counter lives in `lastError`, so "consecutive" is a property of where + // it is stored: any other failure writes a different reason there. + assert.equal(storageTimeoutRun(null), 0); + assert.equal(storageTimeoutRun("worker-error: connection reset"), 0, "a different fault resets it"); + assert.equal(storageTimeoutRun("storage-timeout:1"), 1); + assert.equal(storageTimeoutRun("storage-timeout:2"), 2); + assert.equal(storageTimeoutRun("storage:some other blip"), 0, "a non-timeout storage fault too"); +}); + +test("a row that keeps timing out is PARKED so it stops heading the queue", async () => { + const hung = { ok: false as const, kind: "transient" as const, message: "storage-timeout:download" }; + + // First timeout: retried, and the run is recorded. + const first = harness([workerRow({ lastError: null })], { downloadBytes: async () => hung }); + assert.deepEqual((await runIntakeWorker(first.deps)).byState, { RETRY: 1 }); + assert.equal(first.retried[0].reason, "storage-timeout:1"); + + // Second: still retried, run of two. + const second = harness([workerRow({ lastError: "storage-timeout:1" })], { downloadBytes: async () => hung }); + assert.deepEqual((await runIntakeWorker(second.deps)).byState, { RETRY: 1 }); + assert.equal(second.retried[0].reason, "storage-timeout:2"); + + // Third: parked, with its OWN reason rather than a generic max-retries + // twenty passes later. + const third = harness([workerRow({ lastError: "storage-timeout:2" })], { downloadBytes: async () => hung }); + assert.deepEqual((await runIntakeWorker(third.deps)).byState, { NEEDS_REVIEW: 1 }); + assert.equal(third.states[0].reason, "storage-timeout"); +}); + +test("CONTROL: an ordinary transient storage fault still gets all 20 attempts", async () => { + // Without this, the new ceiling could quietly apply to every storage blip + // and park good receipts after three. + const blip = { ok: false as const, kind: "transient" as const, message: "connection reset" }; + const h = harness([workerRow({ attempts: 5, lastError: "storage:connection reset" })], { + downloadBytes: async () => blip, + }); + assert.deepEqual((await runIntakeWorker(h.deps)).byState, { RETRY: 1 }); + assert.equal(h.retried[0].attempts, 6); + assert.match(h.retried[0].reason, /^storage:/); +}); + +test("the deadline reaches EVERY storage call, not just QuickBooks", () => { + // The wiring: buildDeps threads the INVOCATION's deadline into every + // storage call the pass makes, exactly as it does into the QBO client. + // They are the same deadline, so a pass that has spent fifty of its sixty + // seconds cannot hand the next call a fresh fifteen. + const cron = readFileSync( + path.join(__dirname, "..", "src/app/api/cron/receipt-intake-worker/route.ts"), + "utf8", + ); + assert.equal( + (cron.match(/downloadVerified\(storagePath, expectedSha256, invocationDeadline\)/g) ?? []).length, + 2, + "both the worker's read and the booking's read", + ); + // The stale-STAGING sweep's inspection and the publish's seal, too. Both + // used to be issued with no deadline at all. + assert.match(cron, /inspectStoredObject\(\s*\n\s*row\.storagePath,\s*\n\s*row\.mimeType,\s*\n\s*invocationDeadline,\s*\n\s*\)/); + assert.match(cron, /\}, invocationDeadline\);/, "and sealAndPublish takes it as well"); + // ONE deadline per invocation, created once. + assert.equal( + (cron.match(/createRouteDeadline\(/g) ?? []).length, + 1, + "one deadline for the pass, not one per row", + ); +}); + +// -- The tax warning survives every route to BOOKED (round-20 finding 2) ---- +// +// Routing recorded the marker in `stateReason`, and applyBookResult then +// replaced that column with its own reason on the deferred path -- which is +// EVERY row during the disabled-push cutover, because a disabled push is +// exactly a defer. The BOOKED transition read the marker out of whatever the +// column held by then, so an automatically booked receipt with a bad tax read +// became indistinguishable from one with a clean read. The evidence has its +// own column now. + +test("tax-implausible -> DEFERRED -> BOOKED keeps the marker", async () => { + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, taxAmount: "292.00" } } as ReadOutcome), + }); + await runIntakeWorker(h.deps); + + // What routing durably wrote. + const routed = h.finished[0]; + assert.equal(routed.taxWarning, "tax-implausible"); + + // The deferred booking, exactly as applyBookResult performs it: the + // stateReason column is replaced with the defer reason. Nothing writes + // taxWarning. + const afterDefer = { + taxWarning: routed.taxWarning, + stateReason: "push-disabled", + }; + + // ...and the BOOKED transition still finds it. + assert.equal(preservedTaxWarning(afterDefer), "tax-implausible"); + + // PRE-FIX CONTROL: reading the display copy alone, which is what shipped. + assert.equal( + preservedTaxWarning({ stateReason: afterDefer.stateReason }), + null, + "the old source of truth reports a clean tax read on a receipt that had none", + ); +}); + +test("tax-implausible -> QBO REVIEW park keeps the marker too", async () => { + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, taxAmount: "292.00" } } as ReadOutcome), + }); + await runIntakeWorker(h.deps); + const routed = h.finished[0]; + + // A park writes its own reason into stateReason, the same way. + const parked = { + taxWarning: routed.taxWarning, + stateReason: "qbo-fault:6240", + }; + assert.equal(preservedTaxWarning(parked), "tax-implausible"); + assert.equal(preservedTaxWarning({ stateReason: parked.stateReason }), null, "the control"); + + // And a receipt whose tax read was CLEAN never acquires one. + const clean = harness([workerRow()]); + await runIntakeWorker(clean.deps); + assert.equal(clean.finished[0].taxWarning, null); + assert.equal( + preservedTaxWarning({ taxWarning: clean.finished[0].taxWarning, stateReason: "push-disabled" }), + null, + ); +}); + +test("every routing exit carries the durable marker, not just the READ one", async () => { + // The gated and dedup exits go through applyState with the read patch, so + // the marker rides in `base` rather than being added per branch -- one + // place, and a new exit gets it for free. + const h = harness([workerRow()], { + read: async () => ({ ok: true, read: { ...goodRead.read, taxAmount: "292.00" } } as ReadOutcome), + findWeakHit: async () => ({ id: "row-twin" }), + }); + await runIntakeWorker(h.deps); + assert.equal(h.states[0].reason, "weak-dup:row-twin;tax-implausible", "the display copy"); + assert.equal( + (h.states[0].patch as { taxWarning?: string | null }).taxWarning, + "tax-implausible", + "and the durable one, in the same write", + ); +}); diff --git a/tests/receipt-url.test.ts b/tests/receipt-url.test.ts new file mode 100644 index 000000000..869439aa2 --- /dev/null +++ b/tests/receipt-url.test.ts @@ -0,0 +1,160 @@ +/** + * Expense.receiptUrl holds a REFERENCE, not a link. + * + * A signed URL written into the column is dead ten minutes later — the receipt + * link in the books stops working and nothing says why. A bare storage path + * says nothing about which bucket it is in, which is the ambiguity that had + * receipts and signed contracts sharing one. So the column holds + * `receipt-intake:///` and every reader mints its own short-lived + * URL from it. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + RECEIPT_URL_SCHEME, + isReceiptUrlRef, + parseReceiptUrl, + receiptUrlRef, + resolveReceiptUrl, + resolveReceiptUrls, +} from "../src/lib/receipt-intake/receipt-url"; +import { RECEIPT_BUCKET } from "../src/lib/receipt-intake/bucket"; + +const PATH = "receipts/intake/row-1.png"; +const REF = `${RECEIPT_URL_SCHEME}${RECEIPT_BUCKET}/${PATH}`; + +test("the reference names the bucket AND the path, and round-trips", () => { + assert.equal(receiptUrlRef(PATH), REF); + assert.deepEqual(parseReceiptUrl(REF), { bucket: RECEIPT_BUCKET, path: PATH }); + assert.ok(isReceiptUrlRef(REF)); +}); + +test("anything that is not our reference is not ours to resolve", async () => { + for (const value of [ + null, undefined, "", + "https://evil.test/receipt.png", + "data:image/png;base64,AAAA", + "receipts/intake/row-1.png", + "secure:receipts/intake/row-1.png", + // Another bucket is refused even under our scheme: this string comes + // out of a database column and ends up in a storage API call. + `${RECEIPT_URL_SCHEME}secure-docs/${PATH}`, + `${RECEIPT_URL_SCHEME}${RECEIPT_BUCKET}/../secure-docs/contract.pdf`, + `${RECEIPT_URL_SCHEME}${RECEIPT_BUCKET}//etc/passwd`, + `${RECEIPT_URL_SCHEME}${RECEIPT_BUCKET}/`, + ]) { + assert.equal(parseReceiptUrl(value as string), null, String(value)); + let signed = 0; + const out = await resolveReceiptUrl(value as string, 600, { + sign: async () => { signed++; return "https://signed.test/x"; }, + currentPath: async () => null, + }); + assert.equal(out, null, String(value)); + assert.equal(signed, 0, `${value}: storage was never asked`); + } +}); + +test("a live object is signed for a SHORT window", async () => { + const asked: Array<[string, number]> = []; + const url = await resolveReceiptUrl(REF, 600, { + sign: async (p, ttl) => { asked.push([p, ttl]); return `https://signed.test/${p}`; }, + currentPath: async () => null, + }); + assert.equal(url, `https://signed.test/${PATH}`); + assert.deepEqual(asked, [[PATH, 600]]); +}); + +test("a MOVED object is followed to where the intake row points now", async () => { + // The object moves after the Expense is written: published at the upload + // path, sealed to a content-addressed one, later archived. A reference that + // no longer resolves is re-asked of the row that tracks the bytes. + const sealed = "receipts/row-1/abc123.png"; + const asked: string[] = []; + const url = await resolveReceiptUrl(REF, 600, { + sign: async p => { asked.push(p); return p === sealed ? `https://signed.test/${p}` : null; }, + currentPath: async () => sealed, + }); + assert.deepEqual(asked, [PATH, sealed], "the stored path first, then where it moved to"); + assert.equal(url, `https://signed.test/${sealed}`); +}); + +test("an object that is really gone resolves to null, not to a broken link", async () => { + const gone = await resolveReceiptUrl(REF, 600, { + sign: async () => null, + currentPath: async () => null, + }); + assert.equal(gone, null); + + // And a lookup that points back at the same dead path is not retried. + let signs = 0; + await resolveReceiptUrl(REF, 600, { + sign: async () => { signs++; return null; }, + currentPath: async () => PATH, + }); + assert.equal(signs, 1); +}); + +test("resolveReceiptUrls resolves a whole list, leaving non-references alone", async () => { + const rows = [ + { id: "a", receiptUrl: REF }, + { id: "b", receiptUrl: "https://legacy.test/receipt.png" }, + { id: "c", receiptUrl: null }, + ]; + const signed: string[] = []; + const out = await resolveReceiptUrls(rows, 600, { + sign: async p => { signed.push(p); return `https://signed.test/${p}`; }, + currentPath: async () => null, + }); + assert.deepEqual(out, [ + { id: "a", receiptUrl: `https://signed.test/${PATH}` }, + { id: "b", receiptUrl: "https://legacy.test/receipt.png" }, + { id: "c", receiptUrl: null }, + ]); + // Only the reference was ever handed to storage — the legacy URL and the + // null both passed straight through. + assert.deepEqual(signed, [PATH]); +}); + +test("neither leg can take a page down", async () => { + const out = await resolveReceiptUrl(REF, 600, { + sign: async () => { throw new Error("storage is down"); }, + currentPath: async () => { throw new Error("db is down"); }, + }); + assert.equal(out, null); +}); + +test("every reader resolves the reference: the booker writes it, resolveDocUrl reads it", () => { + const root = path.resolve(__dirname, ".."); + const book = readFileSync(path.join(root, "src/lib/receipt-intake/book.ts"), "utf8"); + assert.ok(/\s{20}receiptUrl,/.test(book), "the Expense is written with it"); + assert.match(book, /receiptUrlRef\(row\.storagePath\)/); + assert.ok(!/createSignedUrl/.test(book), "the booker never mints a link into the column"); + + // resolveDocUrl is the shared reader, so everything already going through + // it resolves the new scheme without learning about it. + const storage = readFileSync(path.join(root, "src/lib/secure-storage.ts"), "utf8"); + assert.match(storage, /if \(isReceiptUrlRef\(stored\)\) return await resolveReceiptUrl\(stored, ttlSeconds\)/); + + // The two readers that do NOT go through it. + const tab = readFileSync(path.join(root, "src/lib/time-expense-actions.ts"), "utf8"); + assert.match(tab, /isReceiptUrlRef\(expense\.receiptUrl\)/); + assert.match(tab, /await resolveReceiptUrl\(expense\.receiptUrl\)/); + + const aiReview = readFileSync(path.join(root, "src/app/api/automation/ai-review/route.ts"), "utf8"); + assert.match(aiReview, /const receiptUrl = isReceiptUrlRef\(expense\.receiptUrl\)/); + // The SSRF check still stands, on the RESOLVED url, and now names the + // signed-object prefix too. + assert.match(aiReview, /storageRoot\}sign\//); + assert.match(aiReview, /allowed\.some\(prefix => receiptUrl\.startsWith\(prefix\)\)/); + assert.match(aiReview, /fetch\(receiptUrl, \{ redirect: "error"/); + + // The bookkeeper review queue renders receiptUrl straight into an href + // (ReceiptQueueClient) for BOTH lists it lists — the actionable "Pending" + // queue and the finalized QBO-imports panel — so both must be resolved + // before they reach the client. + const managerReceipts = readFileSync(path.join(root, "src/app/manager/receipts/page.tsx"), "utf8"); + assert.match(managerReceipts, /resolveReceiptUrls\(pendingExpenses\)/); + assert.match(managerReceipts, /resolveReceiptUrls\(importedExpenses\)/); +}); diff --git a/tests/secure-storage-classification.test.ts b/tests/secure-storage-classification.test.ts new file mode 100644 index 000000000..7714a2c22 --- /dev/null +++ b/tests/secure-storage-classification.test.ts @@ -0,0 +1,64 @@ +/** + * "The object is gone" vs "storage hiccuped" — the distinction that decides + * whether a receipt is parked for a human and its dedup key RELEASED, or simply + * retried. + * + * The expensive direction is the safe-looking one: Supabase returns 400 for a + * malformed request, a bad JWT, an expired service key and assorted config + * faults. Reading those as not-found would empty the queue into review on a key + * rotation and unlock every strong key on the way out. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isNotFoundError } from "../src/lib/secure-storage"; + +test("an affirmative 404 is not-found", () => { + assert.equal(isNotFoundError({ status: 404, message: "Not Found" }), true); + assert.equal(isNotFoundError({ statusCode: "404", message: "anything" }), true); + assert.equal(isNotFoundError({ statusCode: 404 }), true); +}); + +test("an explicit not-found ERROR CODE is not-found", () => { + for (const code of ["NoSuchKey", "not_found", "NOT FOUND", "object_not_found", "EntityNotFound"]) { + assert.equal(isNotFoundError({ error: code }), true, code); + } +}); + +test("the exact not-found MESSAGE is not-found", () => { + assert.equal(isNotFoundError({ message: "Object not found" }), true); + assert.equal(isNotFoundError({ message: "The resource was not found" }), true); +}); + +test("400 is NOT evidence of absence, whatever it says", () => { + // This is the regression. Every one of these used to be read as "gone". + for (const error of [ + { status: 400, message: "Invalid JWT" }, + { status: 400, message: "invalid signature" }, + { status: 400, message: "Bucket not found" }, + { status: 400 }, + ]) { + assert.equal(isNotFoundError(error), false, JSON.stringify(error)); + } +}); + +test("auth, rate-limit, server and network faults are all transient", () => { + for (const error of [ + { status: 401, message: "Unauthorized" }, + { status: 403, message: "forbidden" }, + { status: 429, message: "Too Many Requests" }, + { status: 500, message: "Internal Error" }, + { status: 503, message: "Service Unavailable" }, + { message: "fetch failed" }, + { message: "socket hang up" }, + ]) { + assert.equal(isNotFoundError(error), false, JSON.stringify(error)); + } +}); + +test("a message that merely CONTAINS 'not found' is not enough", () => { + // Substring matching is how a config error ("bucket not found for this + // project", "tenant not found") gets mistaken for a missing object. + assert.equal(isNotFoundError({ message: "bucket not found for this project" }), false); + assert.equal(isNotFoundError({ message: "tenant not found" }), false); + assert.equal(isNotFoundError(null), false); +}); diff --git a/tests/supabase-storage-mock.test.ts b/tests/supabase-storage-mock.test.ts new file mode 100644 index 000000000..f5e9cb0b3 --- /dev/null +++ b/tests/supabase-storage-mock.test.ts @@ -0,0 +1,152 @@ +/** + * The e2e storage stub has to answer the questions production code actually + * asks — including the ones it does not implement. + * + * THE REGRESSION. `receiptObjectSize` (bucket.ts) and `secureObjectSize` + * (secure-storage.ts) both establish "is this object there, and how big is it" + * from `list` metadata, because downloading an 8 MiB receipt to learn its size + * is the exact thing they exist to avoid. The stub had no `list`, so + * `from.list` was `undefined` and the call THREW — and `receiptObjectSize` + * classifies a throw as TRANSIENT, i.e. "storage is having a moment", not "the + * object is gone". + * + * That is not a missing feature, it is a WRONG ANSWER. Every intake replay that + * reached the existence check got 503 instead of the 200-or-heal it had earned, + * so the idempotency contract the whole forwarder design rests on could not be + * exercised at all under the stub. A stub that omits a method does not omit a + * behaviour; it invents one, and the invented one looked like flaky + * infrastructure rather than a bug. + * + * So this file pins two things: the METHOD SURFACE (so the next omission fails + * here, loudly, instead of in a spec that reads as a storage hiccup) and the + * missing/present classification the callers actually branch on. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createStorageMockClient } from "../src/lib/supabase-storage-mock"; +import { receiptObjectSize, RECEIPT_BUCKET, type BucketLister } from "../src/lib/receipt-intake/bucket"; + +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", +); + +function freshBucket() { + // The stub caches objects on globalThis so state survives Next.js + // instantiating the module once per route bundle; tests must not inherit + // each other's writes. + (globalThis as { __e2eStorageMockObjects?: unknown }).__e2eStorageMockObjects = undefined; + return createStorageMockClient().storage.from(RECEIPT_BUCKET); +} + +test("the stub implements every storage method production code calls", () => { + // The list is not decorative: each of these is reached by a real code path + // under E2E_STORAGE_MOCK=1, and a missing one fails as a THROW that the + // callers translate into a transient storage fault. + const api = freshBucket() as unknown as Record; + for (const method of [ + "upload", + "download", + "remove", + "list", + "getPublicUrl", + "createSignedUrl", + "createSignedUploadUrl", + ]) { + assert.equal(typeof api[method], "function", `storage mock is missing ${method}()`); + } +}); + +test("an object that IS there reports its real size, not an unknown", async () => { + const bucket = freshBucket(); + const path = "receipts/intake/abc-123.png"; + const { error } = await bucket.upload(path, PNG, { contentType: "image/png" }); + assert.equal(error, null); + + const size = await receiptObjectSize(path, bucket as unknown as BucketLister, undefined); + assert.deepEqual(size, { ok: true, size: PNG.length }); +}); + +test("an object that is NOT there is MISSING, never transient", async () => { + // This is the assertion that would have caught it. Before `list` existed + // the answer here was {ok:false, kind:"transient"} — and transient is what + // the intake replay path answers 503 to, so a row whose object had never + // landed could not be healed and a replay of one that HAD landed could not + // be confirmed. "Gone" and "storage hiccuped" are different receipts. + const bucket = freshBucket(); + await bucket.upload("receipts/intake/present.png", PNG, { contentType: "image/png" }); + + const absent = await receiptObjectSize( + "receipts/intake/never-uploaded.png", + bucket as unknown as BucketLister, + undefined, + ); + assert.deepEqual(absent, { ok: false, kind: "missing" }); +}); + +test("an EMPTY prefix is an answer, not an error", async () => { + const bucket = freshBucket(); + const empty = await receiptObjectSize( + "receipts/intake/anything.png", + bucket as unknown as BucketLister, + undefined, + ); + assert.deepEqual(empty, { ok: false, kind: "missing" }); +}); + +test("`search` is a PREFIX filter on the name, and names are relative to the dir", async () => { + // storage-api's SQL is `name ilike prefix || search || '%'`, so a substring + // match would be wrong — and `name` comes back relative to the listed + // directory, which is what both callers compare with `entry.name === name`. + const bucket = freshBucket(); + await bucket.upload("receipts/intake/aaa.png", PNG, { contentType: "image/png" }); + await bucket.upload("receipts/intake/aab.png", PNG, { contentType: "image/png" }); + await bucket.upload("receipts/intake/zzz-aaa.png", PNG, { contentType: "image/png" }); + + const { data } = await bucket.list("receipts/intake", { search: "aa", limit: 100 }); + assert.deepEqual(data?.map(e => e.name), ["aaa.png", "aab.png"]); + + const exact = await bucket.list("receipts/intake", { search: "aaa.png", limit: 100 }); + assert.deepEqual(exact.data?.map(e => e.name), ["aaa.png"]); +}); + +test("a sub-folder appears ONCE and carries no metadata", async () => { + // How the real API distinguishes a folder from an object, and what stops a + // directory with 200 objects under it from filling a caller's page. + const bucket = freshBucket(); + await bucket.upload("receipts/intake/one.png", PNG, { contentType: "image/png" }); + await bucket.upload("receipts/nested/a.png", PNG, { contentType: "image/png" }); + await bucket.upload("receipts/nested/b.png", PNG, { contentType: "image/png" }); + + const { data } = await bucket.list("receipts", { search: "", limit: 100 }); + assert.deepEqual(data?.map(e => e.name), ["intake", "nested"]); + assert.deepEqual(data?.map(e => e.metadata), [null, null]); +}); + +test("limit and offset page the listing", async () => { + const bucket = freshBucket(); + for (const name of ["a.png", "b.png", "c.png"]) { + await bucket.upload(`receipts/intake/${name}`, PNG, { contentType: "image/png" }); + } + const first = await bucket.list("receipts/intake", { search: "", limit: 2 }); + assert.deepEqual(first.data?.map(e => e.name), ["a.png", "b.png"]); + + const second = await bucket.list("receipts/intake", { search: "", limit: 2, offset: 2 }); + assert.deepEqual(second.data?.map(e => e.name), ["c.png"]); +}); + +test("a removed object goes back to MISSING", async () => { + const bucket = freshBucket(); + const path = "receipts/intake/gone.png"; + await bucket.upload(path, PNG, { contentType: "image/png" }); + assert.deepEqual( + await receiptObjectSize(path, bucket as unknown as BucketLister, undefined), + { ok: true, size: PNG.length }, + ); + + await bucket.remove([path]); + assert.deepEqual( + await receiptObjectSize(path, bucket as unknown as BucketLister, undefined), + { ok: false, kind: "missing" }, + ); +}); diff --git a/vercel.json b/vercel.json index 07dc9017c..3cdf39c9b 100644 --- a/vercel.json +++ b/vercel.json @@ -52,6 +52,10 @@ "path": "/api/cron/pipeline-digest", "schedule": "0 14 * * *" }, + { + "path": "/api/cron/receipt-intake-worker", + "schedule": "*/5 * * * *" + }, { "path": "/api/cron/qbo-maintenance", "schedule": "45 * * * *"