mega-plant/lib/financing.ts

171 lines
6.8 KiB
TypeScript
Raw Permalink Normal View History

Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
import type { FinancingTerm } from "./data";
/**
* Finance agreements and their settlement maths.
*
* The sheet gives ONE thing: the monthly instalment. Everything else — when the
* agreement started, when it ends, whether it is already settled — is typed in by a
* human in the app, because no sheet records it.
*
* So the governing rule, same as everywhere else in this project: **a figure we
* cannot derive is `null`, never `0`.** A machine with no end date has an unknown
* balance, not a zero balance. Printing R0 outstanding would tell the client a debt
* is cleared when in fact nobody has entered the date yet — the most expensive kind
* of wrong on a page whose whole job is "how much do I still owe".
*
* 0 is reserved for exactly one meaning: the agreement is finished and genuinely
* costs nothing more.
*/
export type FinanceStatus = "financed" | "closing" | "finished" | "settled";
export type FinanceCalc = {
/** Instalments in the whole agreement, start month through end month inclusive. Null when either end is unknown. */
totalMonths: number | null;
/** Instalments still to be paid, counting the current month. Null when the end date is unknown. */
monthsRemaining: number | null;
/** monthsRemaining × instalment. Null when unknown — NEVER 0 for "not entered". */
outstanding: number | null;
/** The whole commitment over the term: totalMonths × instalment. */
totalCommitment: number | null;
/** What has already been paid over the term. Null when the term is unknown. */
paidToDate: number | null;
/** 0-1, or null when the term is unknown. */
progress: number | null;
status: FinanceStatus;
/**
* True when the stored data contradicts itself, or the end date has passed without
* the settled toggle being flipped. Never silently "fixed" — the page shows it and
* asks a human, because deciding whether a debt is live is not a calculation.
*/
needsAttention: boolean;
};
/** Months elapsed since 0 AD, so two dates subtract into a month count. */
function monthIndex(year: number, month: number): number {
return year * 12 + (month - 1);
}
/** A month is only "set" when BOTH the year and the month are real (0 means unknown). */
function hasMonth(year: number, month: number): boolean {
return year > 0 && month >= 1 && month <= 12;
}
export type FinanceInput = {
installment: number;
startYear: number;
startMonth: number;
endYear: number;
endMonth: number;
isSettled: boolean;
/** Defaults to the current month; injected so the maths is testable and stable. */
nowYear: number;
nowMonth: number;
};
/**
* What is still owed on one agreement.
*
* `isSettled` means "paid off, or will be paid off by the end date" — so a settled
* agreement with a FUTURE end date still shows the instalments you owe up to it. Only
* a settled agreement whose end date has already passed reads as zero, because that
* is the one case where the debt genuinely is gone.
*/
export function financeCalc(input: FinanceInput): FinanceCalc {
const { installment, startYear, startMonth, endYear, endMonth, isSettled, nowYear, nowMonth } = input;
const hasStart = hasMonth(startYear, startMonth);
const hasEnd = hasMonth(endYear, endMonth);
// An end date before the start date is contradictory data, not a negative term.
// Every derived figure below is then withheld rather than guessed at — R0 here would
// tell the client a live debt is cleared.
const contradictory = hasStart && hasEnd && monthIndex(endYear, endMonth) < monthIndex(startYear, startMonth);
let totalMonths: number | null = null;
if (hasStart && hasEnd && !contradictory) {
totalMonths = monthIndex(endYear, endMonth) - monthIndex(startYear, startMonth) + 1;
}
let monthsRemaining: number | null = null;
let endHasPassed = false;
if (hasEnd && !contradictory) {
const endIdx = monthIndex(endYear, endMonth);
const nowIdx = monthIndex(nowYear, nowMonth);
endHasPassed = endIdx < nowIdx;
// The instalment for the end month is still owed on that month, so the count is
// inclusive: end is this month → 1 (you pay this month), end is next month → 2.
monthsRemaining = endHasPassed ? 0 : endIdx - nowIdx + 1;
}
const outstanding = monthsRemaining === null ? null : monthsRemaining * installment;
const totalCommitment = totalMonths === null ? null : totalMonths * installment;
const paidToDate = totalMonths === null || monthsRemaining === null
? null
: Math.max(0, totalMonths - monthsRemaining) * installment;
const progress = totalCommitment && paidToDate !== null ? paidToDate / totalCommitment : null;
// Settled wins: it is the client's explicit statement, whatever the dates say.
// An end date already in the past makes the agreement "finished" — which is NOT the
// same as settled, because nobody has actually confirmed the money stopped moving.
const status: FinanceStatus = isSettled
? "settled"
: endHasPassed
? "finished"
: hasEnd && monthsRemaining !== null && monthsRemaining > 0
? "closing"
: "financed";
const needsAttention = contradictory || (endHasPassed && !isSettled);
return { totalMonths, monthsRemaining, outstanding, totalCommitment, paidToDate, progress, status, needsAttention };
}
/**
* The monthly run-rate the business is actually committed to: every agreement still
* being financed. Settled ones are excluded — they have stopped leaving the account.
*/
export function committedMonthly(
terms: FinancingTerm[],
now: { year: number; month: number },
): number {
return terms
.filter((t) => !t.is_settled && financeCalc(toInput(t, now)).status !== "settled")
.reduce((s, t) => s + (t.installment || 0), 0);
}
/** The gap: what the client still has to type before the page can answer anything. */
export function missingTerms(terms: FinancingTerm[]): { noEndDate: number; noStartDate: number } {
let noEndDate = 0;
let noStartDate = 0;
for (const t of terms) {
if (!hasMonth(t.end_year, t.end_month)) noEndDate++;
if (!hasMonth(t.start_year, t.start_month)) noStartDate++;
}
return { noEndDate, noStartDate };
}
/** Normalise a stored row into the calculator's input shape. */
export function toInput(t: FinancingTerm, now: { year: number; month: number }): FinanceInput {
return {
installment: t.installment || 0,
startYear: t.start_year || 0,
startMonth: t.start_month || 0,
endYear: t.end_year || 0,
endMonth: t.end_month || 0,
isSettled: Boolean(t.is_settled),
nowYear: now.year,
nowMonth: now.month,
};
}
export const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/** "Mar 2027", or null when the date was never entered. */
export function formatMonth(year: number, month: number): string | null {
if (!hasMonth(year, month)) return null;
return `${MONTH_LABELS[month - 1]} ${year}`;
}