Skip to content

I-017 β€” Service-invoice batch ZIP silently drops most rows

Symptom

Owner selected 70 service invoices and clicked Download. The ZIP came back with only 25 files; 45 failed with "Failed to fetch". Chrome console showed, for every failed row, a CORS error against a raw signed URL:

Access to fetch at 'https://storage.googleapis.com/aote-pms.firebasestorage.app/
receipts/erl/2026/04/…-erl_invoice_gws_20250930.pdf?GoogleAccessId=…&Signature=…'
from origin 'https://eop.theestablishers.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

All 45 failing rows were legacy Google Workspace invoices (erl_invoice_gws_*.pdf) stored in GCS, not Drive.

Root cause

The batch ZIP is assembled browser-side β€” it fetch()es each file URL and streams the bytes into a ZIP. That means every URL must be same-origin.

ExpenseRecordRow.fileUrl is resolved per source by resolveVendorPdfUrl (lib/accounting/vendorInvoiceFeed.server.ts): - Drive-backed docs (storagePath: 'drive:<id>') β†’ /api/accounting/receipts/<docId>?redirect=pdf (same-origin) - Legacy GCS docs (storagePath: 'receipts/…') β†’ a raw https://storage.googleapis.com/… signed URL

When the batch download fetch()ed those raw GCS URLs from our origin, GCS returned no Access-Control-Allow-Origin header β†’ the browser blocked them. The 25 that succeeded were the Drive-backed rows (same-origin /api/ URL); the 45 that failed were the GCS-backed Workspace rows.

A second, separate bug in the same code path: it built the URL from r.recordId, which for GCP rows is the billing-month string (e.g. "202606"), not a File-Archive document id β€” so GCP rows 404'd. (This is what produced the earlier "5 of 71" / "50 of 70" counts before the CORS bug was isolated.)

Fix

components/records/ExpenseRecordsTab.tsx triggerSelectionDownload β†’ buildItems() now forces every URL same-origin:

const url = r.fileUrl!.startsWith('/api/')
  ? r.fileUrl!.replace('?redirect=pdf', '?stream=pdf')           // Drive (GCP/Workspace/manual)
  : `/api/accounting/receipts/${encodeURIComponent(r.recordId)}?stream=pdf` // legacy GCS β†’ proxy server-side
  • Drive-backed rows keep the correct docId already in fileUrl, just swap redirectβ†’stream so fetch() receives bytes rather than a 3xx into GCS.
  • GCS-backed rows (only ever receipt/workspace/manual sources, whose recordId IS the File-Archive doc id β€” confirmed: getReceipt(id) reads aote-system/file-archive/documents/entries) route through the same endpoint with ?stream=pdf, which fetches the GCS bytes server-side (streamGcsReceiptFile) and returns them same-origin β€” no CORS.

The ?stream=pdf endpoint behaviour already exists in pages/api/accounting/receipts/[id].ts; this change only fixes the URL the client builds.

Decision log

2026-06-29 β€” βœ… Read AGENTS.md before working this ticket. Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD

Continuation of the service-invoice ZIP completeness saga (T-122 handled the "surface skipped rows" UX; this ticket fixes the actual fetch failures). Verified by source-tracing all four ExpenseRecordSource types through resolveVendorPdfUrl and the receipts streaming endpoint; not yet verified against prod data (no GCS creds in this sandbox) β€” owner to confirm a 70/70 download on the preview deploy.