mega-plant/lib/financing.ts
David f85b9378a2 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-09 01:06:27 +02:00

171 lines
No EOL
6.8 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.

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}`;
}