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). */
|
fix(running): charge TLB06 for the months it was live, and not for November
TLB06 was paid off in October 2026, but the running-cost rule dropped a SETTLED
agreement from EVERY month, so the ten instalments it was really paying
(January-October, R290 360,80) vanished from the year to date.
An agreement is now charged for the months it was live, and its paid-off date
decides when the money stopped:
- LIVE: from its start through its paid-off date, or through the current month
when no paid-off date is on record.
- SETTLED: up to its paid-off date - those instalments really were paid - and
nothing at all when there is no paid-off date, because there is then no month
we can say it paid for. "Settled" means paid off, or will be paid off by
that date, so a settled agreement whose date is still ahead is still charged.
TLB06 is charged January-October and NOT November. Nothing else moves: every
other agreement's window already ran to November or beyond.
- Overview YTD R6 241 894,70 -> R6 532 255,50. The November figure is
unchanged at R441 493,12.
- Financing register YTD instalments R4 395 038,56 -> R4 685 399,36, and
"Committed for November" stays R399 548,96.
The Overview and the register still read ONE rule, so their finance year to
date is the same figure - asserted in lib/running-month.test.ts, together with
the TLB06 case and the live-agreement cases.
committedMonthly() in lib/financing.ts was never called and stated the old
rule, so it is gone rather than left lying next to the real one.
2026-11-04 23:56:26 +00:00
|
|
|
|
export function hasMonth(year: number, month: number): boolean {
|
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
|
|
|
|
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 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}`;
|
|
|
|
|
|
}
|