GCP Billing Statements — scrape (NAS) + surface in unified feed for pre-Oct-2025 months
Goal (owner, 2026-06-19)¶
GCP issued billing statements (not formal HK tax invoices) for billing periods
2024-10 → 2025-09 (13 months). GCP only began issuing HK tax invoices around
Oct 2025, which is what T-034 finished wiring up — the NAS scraper now pulls the
8 tax invoices (5410104656 = 2025-10 … 5600028348 = 2026-05) and files them
to Drive 14b. Vendor Invoices as gcp_invoice File-Archive docs, joined to
cost-table rows in the Records page via T-044's resolveVendorPdfUrl.
For the 13 historical months before Oct 2025, no tax invoice exists — but GCP Console does expose a downloadable statement PDF for each. Today the NAS scraper doesn't fetch them and the web app has no representation for them, so those 13 rows in the Records → Receipts: Service Inv sub-tab show a disabled PDF button.
Scope¶
Close the gap end-to-end so every GCP row in the Records feed has a clickable PDF: scrape the statements on the NAS, ingest them as a new File-Archive doc type, fall back to the statement when no tax invoice exists for that month, and keep the existing tax-invoice path untouched.
Coordinating both sides (NAS + web app) — not handed to Codex.
What we know (verified)¶
- GCP Console nav (T-034 final note): the NAS scraper lands on
/billing/0102F7-3FFF33-823945/invoices→ tab "Postpay - Google Cloud Services". Two presets are available on the page:ALL_STATEMENTS(default landing) → all monthly statements (covers every billing period since the account opened, ~21 months as of writing).ALL_STATUTORY_DOCUMENTS("All tax and statutory documents") → the 8 tax-invoice PDFs (Oct 2025+ only). The currentrun-gcp-invoices.shswitches fromALL_STATEMENTStoALL_STATUTORY_DOCUMENTSthen downloads — that's why only the 8 land today.
- Ingest route
pages/api/workspace/billing/ingest.ts: - Discriminator
vendor: 'workspace' | 'gcp'mapping toVENDOR_DOC_TYPE{ workspace: 'workspace_invoice', gcp: 'gcp_invoice' }. - Idempotent on
(subsidiaryId, type, invoiceNumber). - Stores PDF on Drive
14b. Vendor Invoicesand writes a File-Archive doc. - Web-app surface
lib/accounting/vendorInvoiceFeed.server.ts(Phase 2): buildGcpRows()joins each cost-table row to itsgcp_invoicedoc by invoice number → exposespdfUrl;nullwhen no tax invoice exists.lib/accounting/receipts.ts > listGcpInvoices()filterstype === 'gcp_invoice'— no statement type today.- Doc-type registry
lib/accounting/types.ts:2584:DocumentType = 'receipt' | 'invoice_pdf' | 'workspace_invoice' | 'gcp_invoice' | 'vendor_invoice' | 'contract' | 'quote' | 'wopc' | 'other'.
Sub-tasks¶
T-053a — NAS scraper: second pass for statements¶
Add run-gcp-statements.sh (mirrors run-gcp-invoices.sh) on the NAS at
/volume1/docker/workspace-billing/. Env-only, no scraper code change:
GCP_DOCS_URLS=https://console.cloud.google.com/billing/0102F7-3FFF33-823945/invoices
GCP_PRESET_INITIAL=ALL_STATEMENTS
# do NOT switch to ALL_STATUTORY_DOCUMENTS — stay on statements
GCP_PRESET_TARGET=ALL_STATEMENTS
GCP_DOWNLOAD=true
RECON=false
# NEW — discriminator the scraper POSTs to the ingest route
INGEST_VENDOR=gcp-statement
Schedule: monthly cron, the day after the statement period closes. Run once manually now to backfill the 13 historical months.
Open Qs to resolve before running: does the scraper currently hard-code
vendor: 'gcp' in the ingest POST, or is it env-driven? If hard-coded, the
ingest route extension below must remain backward-compatible (statements
ride on a NEW vendor discriminator; existing GCP invoice ingest is
unaffected).
T-053b — Ingest route: new doc type¶
pages/api/workspace/billing/ingest.ts:
- Extend Vendor union to 'workspace' | 'gcp' | 'gcp-statement'.
- Extend VENDOR_DOC_TYPE map: 'gcp-statement': 'gcp_statement'.
- Extend VENDOR_LABEL: 'gcp-statement': 'Google Cloud (statement)'.
- No other logic change — same idempotency key, same Drive folder
(14b. Vendor Invoices), same auth path.
lib/accounting/types.ts:
- Add 'gcp_statement' to DocumentType.
- Add to the VENDOR_DOC_TYPES array (line ~2617) if it gates UI filters.
T-053c — Web app: surface statements as a fallback PDF on GCP rows¶
lib/accounting/receipts.ts:
- New listGcpStatements() mirroring listGcpInvoices() but filtering
type === 'gcp_statement'. Statement number convention TBD by the
scraper output — likely a YYYYMM string lifted from the filename
(the statement carries no Google-issued "invoice number").
lib/accounting/vendorInvoiceFeed.server.ts > buildGcpRows():
- Join cost-table rows to statements by invoice month (YYYYMM) when
no tax-invoice match exists for that month.
- pdfUrl falls back to the statement's /api/accounting/receipts/{id}?
redirect=pdf URL.
- New optional field on VendorInvoiceFeedRow: pdfKind: 'tax_invoice' |
'statement' | null so the UI can tag the row ("Statement" badge on
pre-Oct-2025 months) without changing how pdfUrl is consumed.
T-053d — UI surface (small)¶
components/records/ExpenseRecordsTab.tsx (or its VendorInvoicesTab
equivalent depending on which Records UI is live):
- When a Service Invoice row has pdfKind === 'statement', render a small
Statement tag next to the file link so auditors aren't confused why the
PDF doesn't look like a tax invoice.
Out of scope on purpose¶
- Workspace statements — Workspace invoices are formal documents from day one; no statement gap there.
- Auto-classifying statements vs tax invoices on the client. The NAS
knows which preset it scraped from, so the client never has to guess —
gcp_invoiceandgcp_statementare physically distinct doc types. - GL behavior. Statements are documentary support, not tax-deductible invoices. No change to GL postings or the cost-table-driven amounts.
Success criteria¶
- All 13 historical GCP months (2024-10 → 2025-09) expose a PDF link in the Records → Receipts → Service Inv view.
- New tax invoices in Oct 2025+ continue to use the tax-invoice doc; no regression on the existing 8 months.
- Monthly NAS run files BOTH (statement for the just-closed period if no tax invoice yet, plus any newly available tax invoice).
- File-Archive collection is idempotent across re-runs.
Relates to¶
- T-034 — Migrate GCP invoice PDF fetching → NAS (the original 4-part split; this is functionally a "part b.2" follow-on for statements).
- T-044 — Records page joins GCP cost-table rows to PDFs (the join helper this task extends with a statement fallback).
- Phase 2 unified expense-records feed
(
lib/accounting/vendorInvoiceFeed.server.ts).
Log¶
- 2026-06-19 created (owner). Discovered the gap while reviewing the merged Records Service Invoices sub-tab: pre-Oct-2025 GCP rows have no PDF link even though Console exposes a downloadable statement. T-034's closing note flagged the verification step ("widen the date range if older tax PDFs exist") but stopped short of the statement-side scrape. Spec'd end-to-end before starting implementation.
Decision log¶
2026-06-23 — T-053a scraper parser fixes cherry-picked onto nightly (from PR #760)¶
- ✅ Attestation (Records (Infrastructure)): read
AGENTS.md; signing this task per the "keep the task board current" + "Source line" rules — the parser fixes I'm landing fall under T-053a's scope, so extending this task rather than opening a parallel one. - Source: Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
- Owner directive (verbatim, 2026-06-23):
"If the task included in the PRs are not tasks created and listed on the Task Board, do that, if they are, but you have not signed to those tasks, sign them according to the instruction on AGENTS.MD, while proceed to work on the still open PRs"
- Two scraper bugfixes under T-053a that were running live on the NAS but had never landed on
nightly(so a fresh rebuild would have regressed). Both landed via GitHub's merge of PR #760 as merge commitee348b23(race condition: I'd cherry-picked them locally asf2f6fd8d+745483d1, but PR #760 was merged via the UI in parallel; my push rebased onto the merge and git auto-skipped the duplicates). 3fa313bc(in PR #760, now on nightly viaee348b23) —parseGcpStatementPdfhad been extracting the statement's date+id from the first 6-digit run in the filename, which actually matched the payments-account-id prefix (8353852385434413_20260531.pdf→"835385"). All 20 statements collapsed to one idGCS-83538501with a bogus8353-85-01date. Now extracts from the_YYYYMMDDfilename suffix, with the printed "Statement issue date" as fallback. Also: amount now reads the 2ndHK$figure ("Total new activity"), not the first ("Starting balance").85c998f9(in PR #760, now on nightly viaee348b23) — after the date/id fix, the live backfill ran clean but posted 0/20: the parser synthesisesGCS-YYYYMMDDasinvoiceNumber, while the scraper's ingest loop matched the PDF buffer viaentries.find(e => e.number === rec.invoiceNumber)— whereentry.numberis the original filename stem. So every statement was "no PDF buffer, skip" → clean no-op, no damaged state. Smallest fix: attach the original filename as_entryNameon the parser output and widen the finder to accepte.number === rec._entryName.- End-to-end verified earlier (pre-cherry-pick, against running NAS image): backfill posted
20/20 to
/api/workspace/billing/ingest, every statement now ingestable. Records → Receipts → Service Inv shows the pre-Oct-2025 GCP months with the orange "Statement" tag. - Note on the third commit in PR #760 (
fedcd5f3—claude-nas-channel.mdrunbook): already cherry-picked ontonightlyseparately asda7e223fon 2026-06-22 (this session). PR #760 is closeable with all three commits accounted for. - PR #760: merged via GitHub UI as
ee348b23(final outcome confirmed via webhook). - Branch policy: committed to
nightlyper the 🟢 lockstep operational signal;mainto be FF'd to nightly's tip in the same push. - Blast radius: restricted to
services/workspace-csv-scraper/gcp-invoices.mjs. No app-runtime change; the next NAS scrape after a rebuild will produce correctly-dated/-id'd statement docs that the ingest route already accepts. The existing 20 statements posted live do not need reprocessing. - T-053 overall status: kept at
todo— this signs the subtask fix that landed, not the parent task's completion. T-053b/c/d (doc-type registration + feed join + UI tag) and the monthly cron remain in scope on the parent.
2026-07-01 — done (superseded — shipped)¶
SUPERSEDED-done. GCP billing statements shipped end-to-end on main (gcp_statement doc type, ingest, listGcpStatements, buildGcpRows join, 'Statement' UI tag, NAS scraper; 20/20 verified posted). Residual monthly cron folded into T-147. [owner: close T-053] - Source (edit): EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b