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>
166 lines
No EOL
6.7 KiB
JavaScript
166 lines
No EOL
6.7 KiB
JavaScript
// 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();
|
|
|
|
// 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"); |