- 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>
136 lines
6.4 KiB
TypeScript
136 lines
6.4 KiB
TypeScript
/**
|
||
* 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)),
|
||
};
|
||
});
|
||
}
|