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>
213 lines
No EOL
8.5 KiB
JavaScript
213 lines
No EOL
8.5 KiB
JavaScript
// Create the `invoices` + `invoice_lines` PocketBase collections.
|
|
//
|
|
// Idempotent: safe to re-run. Creates what is missing and reports drift instead of
|
|
// silently leaving a half-shaped schema behind (the project fails loudly rather than
|
|
// inventing data — see AGENTS.md).
|
|
//
|
|
// Usage: node scripts/setup-invoice-collections.mjs
|
|
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, "..");
|
|
|
|
// Load .env.local the same way seed.mjs does — a script whose job is to shape the
|
|
// schema cannot fail on a missing URL and report success.
|
|
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 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");
|
|
|
|
// Mirror the rules the existing collections use so behaviour stays uniform.
|
|
const reference = await pb.collections.getOne("monthly_income");
|
|
const RULES = {
|
|
listRule: reference.listRule,
|
|
viewRule: reference.viewRule,
|
|
createRule: reference.createRule ?? null,
|
|
updateRule: reference.updateRule ?? null,
|
|
deleteRule: reference.deleteRule ?? null,
|
|
};
|
|
console.log(` · rules mirrored from monthly_income (list="${RULES.listRule}")`);
|
|
|
|
const assetsCollection = await pb.collections.getOne("assets");
|
|
const assetsId = assetsCollection.id;
|
|
|
|
async function ensureCollection(name, fields) {
|
|
const existing = await pb.collections.getFullList({ filter: `name = "${name}"`, perPage: 1 });
|
|
if (existing.length) {
|
|
const have = new Set(existing[0].fields.map((f) => f.name));
|
|
const missing = fields.filter((f) => !have.has(f.name));
|
|
if (!missing.length) {
|
|
console.log(` · ${name} already present (${existing[0].fields.length} fields)`);
|
|
} else {
|
|
// Grow the existing schema rather than refusing: the collection is additive and
|
|
// re-seeding rewrites its rows anyway. Report exactly what was added.
|
|
await updateSchema(name, (cur) => [...cur, ...missing], `added to ${name}: ${missing.map((f) => f.name).join(", ")}`);
|
|
}
|
|
return existing[0].id;
|
|
}
|
|
// NOTE: pb.collections.create() serialises an array schema into repeated `fields=`
|
|
// params, which PocketBase rejects with "invalid formatting". The collections API
|
|
// wants `fields` as one real JSON array inside a JSON body — so post it directly
|
|
// with a pre-stringified body, which the SDK passes through untouched.
|
|
const created = await pb.send("/api/collections", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name,
|
|
type: "base",
|
|
fields,
|
|
listRule: RULES.listRule ?? "",
|
|
viewRule: RULES.viewRule ?? "",
|
|
createRule: RULES.createRule ?? "",
|
|
updateRule: RULES.updateRule ?? "",
|
|
deleteRule: RULES.deleteRule ?? "",
|
|
}),
|
|
});
|
|
console.log(` ✓ created ${name} (${created.id}) with ${created.fields.length} fields`);
|
|
return created.id;
|
|
}
|
|
|
|
// Adds one field to an existing collection if it is not there yet. PocketBase has no
|
|
// partial field API — the schema is replaced wholesale — so the current field list is
|
|
// read back and re-sent with the new field appended.
|
|
async function updateSchema(collectionName, transform, label) {
|
|
const coll = await pb.collections.getOne(collectionName);
|
|
const next = transform([...coll.fields]);
|
|
if (JSON.stringify(next) === JSON.stringify(coll.fields)) {
|
|
console.log(` · ${collectionName} schema already current`);
|
|
return;
|
|
}
|
|
await pb.send(`/api/collections/${coll.name}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fields: next }),
|
|
});
|
|
console.log(` ✓ ${label}`);
|
|
}
|
|
|
|
async function ensureField(collectionName, field) {
|
|
await updateSchema(
|
|
collectionName,
|
|
(fields) => (fields.some((f) => f.name === field.name) ? fields : [...fields, field]),
|
|
`added ${collectionName}.${field.name}`
|
|
);
|
|
}
|
|
|
|
// Drops fields that a previous version of the script created and that are no longer
|
|
// part of the model. Explicit list only — never prune anything else.
|
|
async function dropField(collectionName, ...names) {
|
|
await updateSchema(
|
|
collectionName,
|
|
(fields) => fields.filter((f) => !names.includes(f.name)),
|
|
`dropped ${collectionName}.${names.join(", ")}`
|
|
);
|
|
}
|
|
|
|
console.log("\n→ Ensuring collections");
|
|
|
|
const invoicesId = await ensureCollection("invoices", [
|
|
{ name: "invoice_no", type: "text", required: true },
|
|
{ name: "invoice_date", type: "date", required: true },
|
|
{ name: "order_no", type: "text" },
|
|
{ name: "debtor", type: "text" },
|
|
{ name: "debtor_no", type: "text" },
|
|
{ name: "period_from", type: "date" },
|
|
{ name: "period_to", type: "date" },
|
|
{ name: "description", type: "text" },
|
|
{ name: "site", type: "text" },
|
|
{ name: "section", type: "text" },
|
|
{ name: "category", type: "text" },
|
|
{ name: "currency", type: "text" },
|
|
{ name: "vat_reg_no", type: "text" },
|
|
{ name: "source_file", type: "text" },
|
|
{ name: "subtotal", type: "number" },
|
|
{ name: "escalation", type: "number" },
|
|
{ name: "vat", type: "number" },
|
|
{ name: "vat_pct", type: "number" },
|
|
{ name: "total_excl", type: "number" },
|
|
{ name: "total_incl", type: "number" },
|
|
// Original document, uploaded from the Invoices page (drag-and-drop).
|
|
{ name: "source_pdf", type: "file", maxSelect: 1, maxSize: 20971520 },
|
|
{ name: "booked_month", type: "number" },
|
|
{ name: "booked_year", type: "number" },
|
|
{ name: "is_booked", type: "bool" },
|
|
]);
|
|
|
|
const invoiceLinesId = await ensureCollection("invoice_lines", [
|
|
{ name: "invoice", type: "relation", required: true, collectionId: invoicesId, minSelect: 1, maxSelect: 1, cascadeDelete: true },
|
|
{ name: "line_no", type: "number", required: true },
|
|
{ name: "machine_code", type: "text" },
|
|
{ name: "asset_code", type: "text" },
|
|
{ name: "description", type: "text" },
|
|
{ name: "qty", type: "number" },
|
|
{ name: "unit", type: "text" },
|
|
{ name: "rate", type: "number" },
|
|
{ name: "rate_type", type: "text" },
|
|
{ name: "amount_excl", type: "number" },
|
|
{ name: "vat", type: "number" },
|
|
{ name: "total_incl", type: "number" },
|
|
// The machine this line is actually booked against. Deliberately nullable: an
|
|
// invoice line whose target asset is unresolved must stay visibly unbooked
|
|
// rather than be silently charged to a guess.
|
|
{ name: "booked_asset", type: "relation", collectionId: assetsId, minSelect: 1, maxSelect: 1, cascadeDelete: false },
|
|
{ name: "booked_month", type: "number" },
|
|
{ name: "booked_year", type: "number" },
|
|
]);
|
|
|
|
// Provenance back-reference. `npm run seed` wipes monthly_income, so posting an
|
|
// invoice has to be re-runnable without double-counting. Marking each posted row with
|
|
// the invoice line it came from makes booking idempotent and auditable: a row with an
|
|
// empty `invoice_line` is workbook data, a row with one is invoice-derived.
|
|
await ensureField("monthly_income", {
|
|
name: "invoice_line",
|
|
type: "relation",
|
|
collectionId: invoiceLinesId,
|
|
minSelect: 1,
|
|
maxSelect: 1,
|
|
cascadeDelete: false,
|
|
});
|
|
|
|
// `escalation_pct` and `escalation_amount` came from a misreading of the source PDF:
|
|
// the R24,205.13 column is literally headed "PLUS VAT", and the Escalation column is
|
|
// R0.00 on every line. Superseded by vat_pct / vat.
|
|
await dropField("invoices", "escalation_pct");
|
|
await dropField("invoice_lines", "escalation_amount");
|
|
|
|
console.log("\n=== Summary ===");
|
|
console.log(`invoices ${invoicesId}`);
|
|
console.log(`invoice_lines -> invoices (cascade delete)`);
|
|
console.log(`invoice_lines -> assets (booked_asset)`);
|
|
if (process.exitCode) {
|
|
console.error("\n✗ Schema drift detected — see above.");
|
|
} else {
|
|
console.log("\n✅ Invoice collections ready");
|
|
} |