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 swapredirectβstreamsofetch()receives bytes rather than a 3xx into GCS. - GCS-backed rows (only ever receipt/workspace/manual sources, whose
recordIdIS the File-Archive doc id β confirmed:getReceipt(id)readsaote-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.