mega-plant/scripts/book-invoice.mjs

200 lines
8.6 KiB
JavaScript
Raw Normal View History

Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
// 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`);
Invoices: line-item extraction, booking to plant items, VAT-inclusive income Adds an Invoices sub-menu under /income with a summary table (one row per invoice, click to expand the full detail) and the data model behind it. Data: - invoices + invoice_lines PocketBase collections, created idempotently by scripts/setup-invoice-collections.mjs - line items transcribed from PHI 166/167/171 with per-invoice arithmetic validation in the seed; a bad invoice is refused without blocking the rest - invoice-*.json in data/source are auto-imported, so adding an invoice is one file Booking: - manual book-in per line (Booked to), grouped by category - POST /api/invoice-lines/:id/book moves the income row rather than adding a second one, so a booking can be changed freely - scripts/book-invoice.mjs is idempotent: rows are stamped with monthly_income.invoice_line and replaced on re-run - booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat), read by both the app and the CLI so they cannot disagree Money: - the R24,205.13 column on these invoices is PLUS VAT, not escalation. The seed now rejects VAT that disagrees with the stated rate. - booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the VAT stays visible rather than hidden. Uploads: - drag-and-drop PDF attached to invoices.source_pdf - GET /api/invoices/:id/document proxies it — the collection is rule-locked, so a direct PocketBase /api/files link 404s in a browser Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into months[i], so a machine with two income rows in one month (one per invoice, which is normal multi-site use) showed only the last. SS01 September read R20,700 instead of R35,305 while its own YTD included both. Months now accumulate, per-month profit is computed once after accumulation, and lib/rows.test.ts pins it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
// 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");