T 114
uid: T-114 title: Records β Service Invoice register export β make the Invoice Number hyperlink durable (no short-lived signed URLs in the .xlsx) status: done area: accounting-records created: 2026-06-25 updated: 2026-06-25 related: I-013, T-098
Why¶
The owner exported a Register of Service Invoices workbook from Records β Receipts: Service Invoice β Export and clicked on an Invoice Number's hyperlink β it didn't open. Inspection of the exported file (ERL_Register_Invoice_Service_20260625.xlsx, 71 hyperlinks) showed the root cause:
- 25 hyperlinks point at the same-origin streaming endpoint
(
/api/accounting/receipts/<id>?redirect=pdf) β these are fine. - 45 hyperlinks point at
https://storage.googleapis.com/aote-pms.firebasestorage.app/...GCS signed URLs, all sharing oneExpires=1782357996timestamp = ~1 hour after export. Within that 1-hour window the URL works; after, every one of those 45 links is dead. The user clicked one already past expiry β "doesn't work."
In plain language: the workbook stored short-lived signed URLs in a long-lived export artifact. Even when an individual click happens to work, the workbook becomes useless within an hour and is dead by the next day.
Why this happened¶
The Records page's vendor-invoice feed resolves a row's pdfUrl via
lib/accounting/vendorInvoiceFeed.server.ts:resolveVendorPdfUrl:
- For modern drive:-backed records it returns the same-origin streaming URL
(/api/accounting/receipts/<id>?redirect=pdf).
- For legacy Firebase-Storage-backed records it returns a fresh GCS signed URL
(getReceiptDownloadUrl, 1-hour expiry).
The Service-Invoice export accessor (components/records/RecordsApp.tsx, ~line 2138-2153) wrote r.fileUrl verbatim into the workbook. For modern records the URL is durable; for legacy ones it's a ticking 1-hour clock.
These 45 legacy records are exactly the set T-098 is migrating onto Drive (Firebase Storage retirement). Long-term, T-098 erases this class entirely; this task is the durable-export fix that works both today and after the migration.
What changed¶
components/records/RecordsApp.tsx β the Service Invoice export's Invoice Number column now writes the same-origin streaming endpoint for every record, regardless of underlying storage:
const url = r.fileUrl
? `/api/accounting/receipts/${encodeURIComponent(r.recordId)}?redirect=pdf`
: null
- Auth-gated per click (user's session controls access β better than a signed URL anyone holding the link could use).
- The streaming endpoint (
pages/api/accounting/receipts/[id].ts) already does the right thing for both branches:drive:β stream Drive bytes; legacy βgetReceiptDownloadUrlto mint a fresh signed URL at click time and 302 to it. - The workbook itself never carries an expiring URL.
Why it's built this way (decisions that aren't the obvious choice)¶
- Fix in the export accessor, not in
resolveVendorPdfUrl. The page UI (drawer, table cells, "Open PDF" buttons) clicks inside the same session and is unaffected by the 1-hour expiry, so changing the shared resolver would expand blast radius without benefit. The bug is the export artifact β fix it there. recordIdis the same id the/api/accounting/receipts/<id>endpoint accepts (mapServiceInvoiceToExpenseRecordinlib/accounting/expenseRecordsRow.ts:102setsrecordId: row.id, and the feed's modern Drive rows already use that same id). Verified end-to-end before the edit.
Decision log¶
2026-06-25 β opened + fixed¶
- β
Attestation (Records (Infrastructure)): read
AGENTS.md; scope-scanned by scope β T-098 is the storage migration, not the export bug. No covering task; opening a new one. - Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
- Proposed by: the owner. Approved by: the owner (verbatim, 2026-06-25):
"I have just exported this file (From Records > Receipts: Service Invoice > Export), and the hyperlink URL attached to the invoice number doesn't work. Can you help me look into why?"
- Verification:
- Root cause proven against the uploaded export: 45/71 URLs share a single
Expires=1782357996(2026-06-25 03:26 UTC, ~1 h after export); curl-tested one and gotHTTP/2 200 application/pdfβ confirming the URL itself is currently live but ephemeral. URL counts: 45 GCS-signed + 25 same-origin + 1 blank. - Could not run
tsc/vitestin-sandbox (nonode_modules); the change is a small accessor edit using the same id (recordId) the feed already produces for Drive-backed rows, so the code path is symmetric with what's already in production. - Owner to confirm: re-export β click an Invoice Number β PDF opens. Drive-backed rows (already on the same-origin URL) are unchanged behaviour.
- Branch / PR:
claude/records-export-stable-pdf-urlβ PR tomain(branch policy 2026-06-24: π’main/ π΄nightly). - Blast radius: Service Invoices XLSX export only. Receipts XLSX export uses a separate
Filecolumn (Click Herelinking tor.fileUrl); same class of bug for any legacy-GCS receipt, but no owner report yet β deliberately not changed in this PR to keep the diff small and focused on the reported symptom. Tracked as a known sibling in I-013; if/when the receipts export shows the same problem we'll extend the same accessor pattern. - Cross-links: T-098 (Firebase Storage retirement β eliminates the legacy-URL class long term); I-013 (symptom ticket).
Commit index (backfilled 2026-07-01, best-effort Β· Coaching (Diagnostic))¶
Candidate related commits, auto-backfilled from git on main: commits whose message references this task's UID or a PR number it cites. Not verified β this squash-merged history can't yield a precise per-task list, so rows tagged (mentions only) name the task in passing (may be tangential) and untagged work commits may be missing. Treat as a starting point: verify, prune tangential rows, and append any real ones per the AGENTS.md "record every related SHA" policy.
585b79b2026-06-24 β Merge main (1eb290a4) + renumber T-112βT-113 (second collision) (mentions only)60a75e92026-06-25 β feat(t-115): vendor template clean-up + live-sync templates β transactions (mentions only)40c935f2026-06-25 β Merge: feat(records) β bulk-download selected receipts AND service invoices as ZIP (mentions only)51f20202026-06-25 β Merge main + renumber T-114βT-117, T-115βT-118 (renumber-on-merge)ad021372026-06-25 β feat(records): short-URL "Click Here" columns on the Service Invoices + Receipts registers (mentions only)