0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
- {r.payableNow - r.paidNow > 0.005 ? ZAR2(r.payableNow - r.paidNow) : "—"}
+
0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
+ {r.payableNet > 0.005 ? ZAR2(r.payableNet) : "—"}
Payable
-
0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
- {r.payableNow - r.paidNow > 0.005 ? ZAR2(r.payableNow - r.paidNow) : "—"}
+
0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
+ {r.payableNet > 0.005 ? ZAR2(r.payableNet) : "—"}
{r.paidNow > 0 &&
paid {ZAR(r.paidNow)}
}
@@ -227,7 +228,9 @@ export function Suppliers2View({
{p.po_no}
- {p.invoiceCount === 0 ? "no invoice yet" : `${ZAR2(p.invoiced)} of ${ZAR2(p.total)} invoiced`}
+ {p.invoiceCount === 0 ? "no invoice yet" : p.status === "over"
+ ? {ZAR2(p.invoiced)} invoiced — {ZAR2(p.invoiced - p.total)} OVER the PO
+ : `${ZAR2(p.invoiced)} of ${ZAR2(p.total)} invoiced`}
{p.dueMonth && ` · pay ${monthLabel(p.dueMonth)}`}
{ZAR2(p.total)}
diff --git a/lib/purchase-invoice-status.ts b/lib/purchase-invoice-status.ts
index 2a2ee9e..85d70d0 100644
--- a/lib/purchase-invoice-status.ts
+++ b/lib/purchase-invoice-status.ts
@@ -3,16 +3,18 @@
/** Invoiced within this many rand of the PO total counts as fully invoiced (rounding). */
export const CLOSE_TOLERANCE = 1;
-export type InvoiceStatus = "none" | "partial" | "complete";
+export type InvoiceStatus = "none" | "partial" | "complete" | "over";
/**
* red = no invoice; amber = some invoiced but the money does not cover the PO (an invoice with
* no amount typed in also lands here, since its value is unknown); teal = invoiced to within R1
- * of the PO total, so it can be closed.
+ * of the PO total, so it can be closed; purple = invoiced MORE than the PO by over R1. That is
+ * never "closed": it is a typo, a duplicate or a changed order, and a person has to look.
*/
export function invoiceStatusOf(total: number, amounts: (number | null)[]): { status: InvoiceStatus; invoiced: number } {
const invoiced = amounts.reduce
((s, a) => s + (a ?? 0), 0);
if (amounts.length === 0) return { status: "none", invoiced };
+ if (invoiced - total > CLOSE_TOLERANCE) return { status: "over", invoiced };
if (invoiced > 0 && total - invoiced <= CLOSE_TOLERANCE) return { status: "complete", invoiced };
return { status: "partial", invoiced };
}
@@ -21,4 +23,5 @@ export const STATUS_STYLE: Record a.slice(0, i.month).reduce((x, y) => x + y, 0);
const owingToDate = upTo(dueByMonth) - upTo(paidByMonth);
+ const upToPrev = (a: number[]) => a.slice(0, i.month - 1).reduce((x, y) => x + y, 0);
+ const broughtForward = Math.max(Math.min(upToPrev(dueByMonth) - upToPrev(paidByMonth), owingToDate), 0);
- // Supplier colour: every PO fully invoiced → teal; nothing invoiced anywhere → red.
+ // Supplier colour: any PO over-invoiced → purple (needs a look); every PO fully invoiced → teal; nothing invoiced anywhere → red.
const status: SupplierSpendRow["status"] = pos.length === 0 ? "n/a"
+ : pos.some((p) => p.status === "over") ? "over"
: pos.every((p) => p.status === "complete") ? "complete"
: pos.every((p) => p.status === "none") ? "none" : "partial";
@@ -116,6 +125,7 @@ export function buildSupplierSpend(i: SpendInput): SupplierSpendRow[] {
id: s.id, name: s.name, code: s.code, terms: s.terms, isCash: s.terms === "cash",
months: months.map((v) => (v > 0 ? v : null)),
dueByMonth, paidByMonth, paidNow: paidByMonth[i.month - 1], owingToDate,
+ payableNet: Math.max(owingToDate, 0), broughtForward, surplus: Math.max(-owingToDate, 0),
payments: payments.map((x) => ({ id: x.id, paid_on: x.paid_on, description: x.description, amount: x.amount }))
.sort((a, b) => b.paid_on.localeCompare(a.paid_on)),
ytd: months.reduce((a, b) => a + b, 0), thisMonth: months[i.month - 1],
diff --git a/scripts/setup-invoice-edits-collection.mjs b/scripts/setup-invoice-edits-collection.mjs
new file mode 100644
index 0000000..e723af4
--- /dev/null
+++ b/scripts/setup-invoice-edits-collection.mjs
@@ -0,0 +1,118 @@
+// Create the `purchase_invoices` PocketBase collection.
+//
+// Audit log of edits to a supplier invoice (amount / date / number). Append-only: the app
+// never updates or deletes a row. The invoice and PO numbers are copied as TEXT so the log
+// still reads correctly if the invoice itself is later deleted.
+//
+// Idempotent: safe to re-run.
+//
+// Usage: node scripts/setup-invoice-edits-collection.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}")`);
+
+
+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;
+ }
+ 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 collection");
+
+const invId = (await pb.collections.getOne("purchase_invoices")).id;
+
+await ensureCollection("purchase_invoice_edits", [
+ { name: "invoice", type: "relation", collectionId: invId, minSelect: 0, maxSelect: 1, cascadeDelete: false },
+ { name: "po_no", type: "text" },
+ { name: "invoice_no", type: "text" },
+ { name: "field", type: "text" },
+ { name: "old_value", type: "text" },
+ { name: "new_value", type: "text" },
+ { name: "reason", type: "text", required: true },
+ { name: "edited_by", type: "text" },
+ { name: "created", type: "date", onCreate: true, presentable: false },
+ { name: "updated", type: "date", onUpdate: true, presentable: false },
+]);
+
+console.log("\n✓ purchase_invoice_edits ready.");
+process.exit(0);