Skip to content

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 one Expires=1782357996 timestamp = ~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 β†’ getReceiptDownloadUrl to 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.
  • recordId is the same id the /api/accounting/receipts/<id> endpoint accepts (mapServiceInvoiceToExpenseRecord in lib/accounting/expenseRecordsRow.ts:102 sets recordId: 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 got HTTP/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/vitest in-sandbox (no node_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 to main (branch policy 2026-06-24: 🟒 main / πŸ”΄ nightly).
  • Blast radius: Service Invoices XLSX export only. Receipts XLSX export uses a separate File column (Click Here linking to r.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.

  • 585b79b 2026-06-24 β€” Merge main (1eb290a4) + renumber T-112β†’T-113 (second collision) (mentions only)
  • 60a75e9 2026-06-25 β€” feat(t-115): vendor template clean-up + live-sync templates β†’ transactions (mentions only)
  • 40c935f 2026-06-25 β€” Merge: feat(records) β€” bulk-download selected receipts AND service invoices as ZIP (mentions only)
  • 51f2020 2026-06-25 β€” Merge main + renumber T-114β†’T-117, T-115β†’T-118 (renumber-on-merge)
  • ad02137 2026-06-25 β€” feat(records): short-URL "Click Here" columns on the Service Invoices + Receipts registers (mentions only)