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.
158 lines
No EOL
6.4 KiB
TypeScript
158 lines
No EOL
6.4 KiB
TypeScript
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). */
|
||
export 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 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}`;
|
||
} |