// Create the `financing_terms` PocketBase collection. // // WHY ITS OWN COLLECTION (and not more rows in `cost_lines`): // `cost_lines` answers "what did this machine cost in month M" — one row per month. // A finance AGREEMENT is a different thing: a monthly instalment, a term, and a // settlement state that a human edits as agreements are paid off. Putting that in // a per-month cost table would mean re-keying the same instalment twelve times and // losing the one field people actually come to update — is this still being paid? // // This is deliberately SEPARATE from `insurance_rates`. Insurance and financing are // independent commitments that happen to share a rate sheet; one collection must not // be able to corrupt the other. // // ⚠️ The seed RE-CREATES this collection's rows from data/source/financing-terms.json, // but it PRESERVES the client-entered `start_*`, `end_*` and `is_settled` fields across // a re-seed. Those are typed in by a human in the app and are not recoverable from any // sheet — losing them on every seed would make the page unusable. // // Idempotent: safe to re-run. // // Usage: node scripts/setup-financing-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, ".."); 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"); 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 assetsId = (await pb.collections.getOne("assets")).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 absent = fields.filter((f) => !have.has(f.name)); if (!absent.length) { console.log(` · ${name} already present (${existing[0].fields.length} fields)`); } else { await updateSchema(name, (cur) => [...cur, ...absent], `added to ${name}: ${absent.map((f) => f.name).join(", ")}`); } return existing[0].id; } // pb.collections.create() serialises an array schema into repeated `fields=` // params, which PocketBase rejects. Post the JSON body directly instead. 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; } 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}`); } console.log("\n→ Ensuring collections"); await ensureCollection("financing_terms", [ { name: "asset", type: "relation", required: true, collectionId: assetsId, minSelect: 1, maxSelect: 1, cascadeDelete: true }, // Monthly instalment INCL VAT — what Bastion actually invoices (client note 2026-10-08). // // ⚠️ DELIBERATELY NOT `required`. A PAID-OFF agreement (WB02, DT01) has no live // instalment, and PocketBase rejects BOTH 0 and null on a required number field // ("Cannot be blank" — it treats a zero value as blank). Marking this required made // the seed abort on those two rows and left the database half-built. An instalment // of null now means "not in play, because this agreement is closed". // // The rule that an ACTIVE agreement must have an instalment is enforced where the // business rule lives — `scripts/seed.mjs` — not in the schema. { name: "installment", type: "number" }, // Agreement start. 0 on the year or month means UNKNOWN, never "January". // An unknown start is not the same as a start, and a blank term reads as a wrong one. { name: "start_year", type: "number" }, { name: "start_month", type: "number" }, // Agreement end / final instalment date. Same 0-means-unknown rule. { name: "end_year", type: "number" }, { name: "end_month", type: "number" }, // The toggle: TRUE = settled, i.e. this machine is paid off or will be paid off by // its end date. FALSE = still being financed. Settling does NOT delete the row — // the history stays readable, the machine just stops accruing. { name: "is_settled", type: "bool" }, { name: "plant", type: "text" }, { name: "source", type: "text" }, { name: "created", type: "date", onCreate: true, presentable: false }, { name: "updated", type: "date", onUpdate: true, presentable: false }, ]); // MIGRATION: drop `required` from `installment`. // ensureCollection() only ever ADDS fields, so re-running the block above would // leave an already-created `installment` required forever. PocketBase treats a 0 // value as blank, so a paid-off agreement could never be written and the seed // aborted mid-run, leaving the database half-built. Relax it explicitly, once. const fin = await pb.collections.getOne("financing_terms"); const installment = fin.fields.find((f) => f.name === "installment"); if (installment && installment.required) { await updateSchema( "financing_terms", (fields) => fields.map((f) => (f.name === "installment" ? { ...f, required: false } : f)), "relaxed financing_terms.installment — a paid-off agreement has no live instalment", ); } else { console.log(" · financing_terms.installment already optional"); } console.log("\n✓ financing_terms ready. Next: npm run seed && node scripts/book-invoice.mjs"); process.exit(0);