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