/** * Backfill `customer_invoice_number`, `invoiced_date` and (where it can * be known) `qty_invoiced` on order_items that were invoiced through the * Invoicing page while its reconcile call was 401-ing. * * DRY RUN by default — reads only and prints the plan: * * npx tsx scripts/migrations/2026-09-25-backfill-invoice-reconcile.ts * * Write the plan (after reviewing it): * * npx tsx scripts/migrations/2026-09-25-backfill-invoice-reconcile.ts --apply * * Never changes `item_status`. Rows that need a human decision (short * pickups, unknown billed qty, `ready_invoice` rows that already carry a * QuickBooks invoice) are listed, not touched beyond the stamps. Logic * and rules: src/lib/server/invoice-backfill.ts. * * Writes carry X-Audit-* headers so the audit_log hook records them as * "Invoice backfill". */ import "./_env-loader"; import PocketBase from "pocketbase"; import { planInvoiceBackfill, type BackfillRow } from "../../src/lib/server/invoice-backfill"; const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; const APPLY = process.argv.includes("--apply"); async function main() { const email = process.env.PB_ADMIN_EMAIL; const password = process.env.PB_ADMIN_PASSWORD; if (!email || !password) throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD."); const pb = new PocketBase(PB_URL); pb.autoCancellation(false); await pb.collection("_superusers").authWithPassword(email, password); pb.beforeSend = (url, options) => { options.headers = { ...(options.headers as Record | undefined), "X-Audit-Source": "system", "X-Audit-Actor-Name": encodeURIComponent("Invoice backfill 2026-09-25"), }; return { url, options }; }; console.log(`[invoice-backfill] ${APPLY ? "APPLY" : "DRY RUN"} against ${PB_URL}`); const rows = await pb.collection("order_items").getFullList({ // Everything that could carry an invoice; the plan filters to // QuickBooks invoices precisely. filter: 'order_item_details != null && order_item_details != ""', fields: "id,order,description,item_status,quantity,qty_collected,qty_invoiced," + "customer_invoice_number,invoiced_date,exclude_from_invoice,order_item_details", batch: 1000, }); const plan = planInvoiceBackfill(rows); console.log( `[invoice-backfill] scanned ${plan.scanned} | no QB invoice ${plan.skippedNoQbInvoice} | ` + `already reconciled ${plan.skippedAlreadyReconciled} | to stamp ${plan.patches.length} | ` + `need a decision ${plan.needsDecision.length}`, ); const withQty = plan.patches.filter((p) => p.patch.qty_invoiced !== undefined).length; console.log(`[invoice-backfill] of the stamps, ${withQty} also set qty_invoiced`); if (plan.needsDecision.length > 0) { console.log("\n[invoice-backfill] NEEDS A DECISION (item_status left unchanged):"); console.table( plan.needsDecision.map((d) => ({ id: d.id, order: d.order, invoice: d.invoiceNumber, status: d.item_status, qty: d.quantity, collected: d.qty_collected, billed_est: d.estimatedBilled, reason: d.reason, description: (d.description ?? "").slice(0, 40), })), ); } if (!APPLY) { console.log("\n[invoice-backfill] Dry run only — nothing written. Re-run with --apply to write the stamps."); return; } let written = 0; for (const { id, patch } of plan.patches) { await pb.collection("order_items").update(id, patch); written += 1; } console.log(`[invoice-backfill] Wrote ${written} row(s).`); } main().catch((err) => { console.error("[invoice-backfill] Failed:", err); process.exit(1); });