170 lines
7.3 KiB
JavaScript
170 lines
7.3 KiB
JavaScript
|
|
// 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);
|