mega-plant/lib/supplier-spend.ts
David 642d23dd24 Suppliers: replace the menu entry with the new page, flag over-invoiced POs, audited invoice amount corrections
- Menu: old Suppliers leaf hidden (route kept), Suppliers 2 renamed Suppliers
- PO invoice status: new 'over' state (never closed), live over-PO warning when typing
- Admin correction of invoice amount/date/number with mandatory reason, logged to purchase_invoice_edits
- Payable now carries unpaid balance forward month to month

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 20:49:05 +02:00

136 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Suppliers 2: spend and payables per supplier. Pure — client-safe.
*
* Spend = purchase orders (by PO date) + petty-cash slips (by slip date), per month.
* Payable = supplier invoices on an ACCOUNT supplier, due `terms` days after the invoice
* month: a 30-day account invoiced in October is paid in November, 60 days in December.
* Cash suppliers are paid on the spot, so they owe nothing. A supplier whose terms are not
* set yet is reported separately — never guessed into a month.
* Payments made to account suppliers are entered by hand (`supplier_payments`) and netted
* against what fell due IN THE SAME MONTH: paid − due is a surplus when positive and a
* shortfall when negative. A shortfall in a past month is money still owing.
*/
import { invoiceStatusOf, type InvoiceStatus } from "@/lib/purchase-invoice-status";
import { isAccount } from "@/lib/supplier";
export type SpendInput = {
suppliers: { id: string; name: string; code: string; terms: string }[];
purchases: {
id: string; po_no: string; po_date: string; total: number; supplier_ref: string;
invoices: { invoice_no: string; invoice_date: string; amount_incl: number | null }[];
}[];
slips: { amount: number; at: string; supplier_ref: string }[];
payments: { id: string; supplier_ref: string; paid_on: string; description: string; amount: number }[];
year: number;
/** 1–12 */
month: number;
};
export type SupplierPo = {
id: string; po_no: string; po_date: string; total: number;
invoiced: number; invoiceCount: number; status: InvoiceStatus;
/** "2026-11" the invoices fall due, "" when none or terms not applicable. */
dueMonth: string;
};
export type SupplierPayment = { id: string; paid_on: string; description: string; amount: number };
export type SupplierSpendRow = {
id: string; name: string; code: string; terms: string; isCash: boolean;
months: (number | null)[];
/** Fell due per month this year (invoice month + terms). */
dueByMonth: number[];
/** Paid per month this year. */
paidByMonth: number[];
paidNow: number;
/** Due up to and including this month, minus paid in the same months: positive = still owing. */
owingToDate: number;
/** What is payable NOW: everything due up to and including this month, less everything paid, so an unpaid month rolls into the next on the 1st. 0 when settled or ahead. */
payableNet: number;
/** The part of payableNet that fell due in EARLIER months. */
broughtForward: number;
/** Paid above what has fallen due. */
surplus: number;
payments: SupplierPayment[];
ytd: number; thisMonth: number;
payableNow: number; payableNext: number; payableLater: number; termsUnsetInvoiced: number;
status: InvoiceStatus | "n/a";
pos: SupplierPo[];
};
const ym = (iso: string): { y: number; m: number } | null => {
const r = /^(\d{4})-(\d{2})/.exec(iso || "");
return r ? { y: Number(r[1]), m: Number(r[2]) } : null;
};
const key = (y: number, m: number) => `${y}-${String(m).padStart(2, "0")}`;
export function buildSupplierSpend(i: SpendInput): SupplierSpendRow[] {
const nowIdx = i.year * 12 + (i.month - 1);
return i.suppliers.map((s) => {
const months: number[] = Array(12).fill(0);
const pos: SupplierPo[] = [];
const dueByMonth: number[] = Array(12).fill(0);
const paidByMonth: number[] = Array(12).fill(0);
let payableNow = 0, payableNext = 0, payableLater = 0, termsUnsetInvoiced = 0;
const lag = isAccount(s.terms) ? Number(s.terms) / 30 : 0;
for (const p of i.purchases.filter((x) => x.supplier_ref === s.id)) {
const d = ym(p.po_date);
if (d && d.y === i.year) months[d.m - 1] += p.total;
const st = invoiceStatusOf(p.total, p.invoices.map((v) => v.amount_incl));
let dueMonth = "";
for (const v of p.invoices) {
if (v.amount_incl == null) continue;
// Invoice date, falling back to the PO date when none was typed in.
const base = ym(v.invoice_date) ?? d;
if (!isAccount(s.terms)) {
if (s.terms === "") termsUnsetInvoiced += v.amount_incl;
continue;
}
if (!base) continue;
const idx = base.y * 12 + (base.m - 1) + lag;
dueMonth = key(Math.floor(idx / 12), (idx % 12) + 1);
if (Math.floor(idx / 12) === i.year) dueByMonth[idx % 12] += v.amount_incl;
if (idx === nowIdx) payableNow += v.amount_incl;
else if (idx === nowIdx + 1) payableNext += v.amount_incl;
else if (idx > nowIdx) payableLater += v.amount_incl;
}
pos.push({
id: p.id, po_no: p.po_no, po_date: p.po_date, total: p.total,
invoiced: st.invoiced, invoiceCount: p.invoices.length, status: st.status, dueMonth,
});
}
for (const sl of i.slips.filter((x) => x.supplier_ref === s.id)) {
const d = ym(sl.at);
if (d && d.y === i.year) months[d.m - 1] += sl.amount;
}
const payments = i.payments.filter((x) => x.supplier_ref === s.id);
for (const pm of payments) {
const d = ym(pm.paid_on);
if (d && d.y === i.year) paidByMonth[d.m - 1] += pm.amount;
}
const upTo = (a: number[]) => 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: 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";
return {
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],
payableNow, payableNext, payableLater, termsUnsetInvoiced, status,
pos: pos.sort((a, b) => b.po_date.localeCompare(a.po_date)),
};
});
}