// Post invoice lines into `monthly_income` as ADDITIONAL rows. // // Run order matters: `npm run seed` wipes monthly_income and re-imports the workbook, // so invoice-derived income must be (re)posted afterwards. // // Idempotent: every row it creates is stamped with `invoice_line`, so a re-run removes // the previous invoice-derived rows first instead of adding duplicates. Workbook rows // (empty invoice_line) are never touched. // // Usage: node scripts/book-invoice.mjs [--invoice "PHI 166"] [--dry-run] import PocketBase from "pocketbase"; import { readFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); const envPath = resolve(ROOT, ".env.local"); if (!existsSync(envPath)) { console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in."); process.exit(1); } for (const line of readFileSync(envPath, "utf8").split("\n")) { const t = line.trim(); if (!t || t.startsWith("#")) continue; const i = t.indexOf("="); if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim(); } const PB_URL = process.env.NEXT_PUBLIC_PB_URL; const EMAIL = process.env.PB_ADMIN; const PASS = process.env.PB_PASS; const missing = []; if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL"); if (!EMAIL) missing.push("PB_ADMIN"); if (!PASS) missing.push("PB_PASS"); if (missing.length) { console.error(`✗ Missing required env: ${missing.join(", ")}`); process.exit(1); } const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const onlyIdx = args.indexOf("--invoice"); const onlyInvoice = onlyIdx >= 0 ? args[onlyIdx + 1] : null; // Shared policy — the app reads the same file, so the booked amount can never differ // between this script and the /income/invoices UI. See lib/invoice-booking-policy.json. const POLICY = JSON.parse(readFileSync(resolve(ROOT, "lib", "invoice-booking-policy.json"), "utf8")); const round2 = (n) => Math.round((n || 0) * 100) / 100; const bookedAmount = (excl, vat) => (POLICY.bookInclusiveVat ? round2(excl) + round2(vat) : round2(excl)); const BOOKED_LABEL = POLICY.bookInclusiveVat ? "incl VAT" : "ex VAT"; const pb = new PocketBase(PB_URL); pb.autoCancellation(false); console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`); await pb.collection("_superusers").authWithPassword(EMAIL, PASS); console.log(" ✓ authenticated"); const invoices = await pb.collection("invoices").getFullList({ sort: "invoice_date" }); if (!invoices.length) { console.error("✗ No invoices found. Run `npm run seed` first."); process.exit(1); } const targets = onlyInvoice ? invoices.filter((i) => i.invoice_no === onlyInvoice) : invoices; if (!targets.length) { console.error(`✗ No invoice matched "${onlyInvoice}". Available: ${invoices.map((i) => i.invoice_no).join(", ")}`); process.exit(1); } const allLines = await pb.collection("invoice_lines").getFullList({ sort: "line_no" }); const existingIncome = await pb.collection("monthly_income").getFullList(); // ---- Repair stale booking pointers ------------------------------------------------ // `booked_asset` stores an asset RECORD ID, and `npm run seed` deletes and re-creates // every asset — so the id a line was booked against stops existing after a seed. The line // then points at nothing and silently drops out of monthly_income, which is exactly the // "it booked yesterday and is unbooked today" symptom. // // The invoice line already carries the register code it was matched on (`asset_code`), // so the pointer can be re-derived rather than retyped. This ONLY ever points a line at // the asset its own code names; it never invents a match and never clears one, so a line // with no code stays unbooked exactly as before. const allAssets = await pb.collection("assets").getFullList(); const assetIds = new Set(allAssets.map((a) => a.id)); const idByCode = new Map(allAssets.filter((a) => a.code).map((a) => [String(a.code).trim().toUpperCase(), a.id])); const codeFromMachine = (code) => { const m = /^([A-Za-z]+)[- ]?([0-9]{1,3})/.exec(String(code || "").trim()); return m ? `${m[1].toUpperCase()}${m[2]}` : ""; }; let repaired = 0; if (!dryRun) { for (const l of allLines) { if (l.booked_site) continue; // booked to a site — a different, deliberate choice const fresh = (String(l.asset_code || "").trim().toUpperCase() || codeFromMachine(l.machine_code)) && idByCode.get(String(l.asset_code || "").trim().toUpperCase() || codeFromMachine(l.machine_code)); // Only act when the pointer is genuinely gone: empty, or pointing at a deleted asset. const needsRepair = !l.booked_asset || !assetIds.has(l.booked_asset); if (!needsRepair || !fresh || fresh === l.booked_asset) continue; await pb.collection("invoice_lines").update(l.id, { booked_asset: fresh }); l.booked_asset = fresh; repaired++; } } if (repaired) console.log(` · re-linked ${repaired} line(s) whose target asset had been replaced by a seed`); // Remove previously invoice-derived rows for the target invoices only. const targetIds = new Set(targets.map((i) => i.id)); const linesOfTargets = allLines.filter((l) => targetIds.has(l.invoice)); const lineIds = new Set(linesOfTargets.map((l) => l.id)); const staleRows = existingIncome.filter((r) => r.invoice_line && lineIds.has(r.invoice_line)); console.log(`\n→ Booking ${targets.length} invoice(s), ${linesOfTargets.length} line(s)`); if (dryRun) console.log(" (dry run — nothing will be written)"); if (staleRows.length) { console.log(` · removing ${staleRows.length} previously posted invoice row(s)`); if (!dryRun) for (const r of staleRows) await pb.collection("monthly_income").delete(r.id); } const errors = []; const unbooked = []; let posted = 0; for (const inv of targets) { console.log(`\n ${inv.invoice_no} — ${inv.debtor} — ${inv.description}`); console.log(` period ${inv.period_from} → ${inv.period_to} · booking to ${inv.booked_year}-${String(inv.booked_month).padStart(2, "0")}`); const lines = allLines.filter((l) => l.invoice === inv.id); let invoiceComplete = true; for (const l of lines) { if (!l.booked_asset) { unbooked.push(` ${inv.invoice_no} line ${l.line_no} (${l.machine_code} ${l.description}) — no target asset`); invoiceComplete = false; console.log(` line ${l.line_no} ${l.machine_code.padEnd(14)} → UNBOOKED (no target asset)`); continue; } const asset = await pb.collection("assets").getOne(l.booked_asset).catch(() => null); if (!asset) { errors.push(` ${inv.invoice_no} line ${l.line_no}: booked_asset ${l.booked_asset} no longer exists in assets`); invoiceComplete = false; continue; } // Amount posted follows the shared policy (VAT-inclusive per the client's 2026-10-01 // ruling). Flip lib/invoice-booking-policy.json and re-run to change it. const amount = bookedAmount(l.amount_excl, l.vat); const arrow = asset.code && l.asset_code && asset.code !== l.asset_code ? ` (invoice code ${l.asset_code} → booked to ${asset.code})` : ""; console.log(` line ${l.line_no} ${String(l.machine_code).padEnd(14)} → ${String(asset.code || asset.name).padEnd(8)} R${amount.toFixed(2)} ${BOOKED_LABEL}${arrow}`); if (!dryRun) { try { await pb.collection("monthly_income").create({ asset: l.booked_asset, year: l.booked_year || inv.booked_year, month: l.booked_month || inv.booked_month, income: amount, fixed_expenses: 0, is_forecast: false, invoice_line: l.id, }); posted++; } catch (e) { errors.push(` ${inv.invoice_no} line ${l.line_no}: ${e.response?.data?.message || e.message}`); } } else { posted++; } } if (!dryRun) { await pb.collection("invoices").update(inv.id, { is_booked: invoiceComplete }); } } console.log(`\n=== Summary ===`); console.log(`Invoices processed: ${targets.length}`); console.log(`Rows posted: ${posted}`); console.log(`Unbooked lines: ${unbooked.length}`); if (unbooked.length) { console.log("\n ⚠ These lines are NOT in monthly_income:"); for (const u of unbooked) console.log(` -${u}`); console.log("\n Assign the target asset in data/source/invoice-phi166.json (asset_code) and re-run"); console.log(" `npm run seed`, then `node scripts/book-invoice.mjs`."); } if (errors.length) { console.error(`\n✗ ${errors.length} error(s):`); for (const e of errors) console.error(e); process.exit(2); } console.log(dryRun ? "\n✅ Dry run complete — no changes written" : "\n✅ Booking complete");