From 72900878baa35c4e4e83edeb49d85fab4cb7c281 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 01:56:26 +0200 Subject: [PATCH] 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. --- AGENTS.md | 18 ++-- app/(dashboard)/running/financing/page.tsx | 2 +- lib/financing.ts | 15 +-- lib/running-costs.ts | 79 +++++++++++---- lib/running-month.test.ts | 110 ++++++++++++++++++++- 5 files changed, 180 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1728c0f..897901b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -655,12 +655,18 @@ way: `/running`, `/running/insurance` and `/running/financing`. Overview's insurance YTD = the insurance page's YTD. The Overview's finance YTD = the financing page's YTD. Both read ONE rule - `financeByAsset` / `fleetFinanceByMonth` in `lib/running-costs.ts` - so the three screens cannot drift apart. - `lib/running-month.test.ts` asserts the insurance cross-check; keep it passing. -- **A live agreement is charged every month; a settled one is charged nothing.** TLB06 is - marked settled, so it contributes nothing to ANY month - the instalments it was actually - paying in January-October 2026 are therefore NOT in the Overview's finance figure. That - is the rule the client confirmed ("I think financing is correct"); do not "fix" it by - charging a settled agreement for the months before it was settled. + `lib/running-month.test.ts` asserts BOTH cross-checks - the insurance year to date and the + finance year to date - and the TLB06 case; keep them passing. +- **An agreement is charged for the months it was LIVE, and its paid-off date decides.** + TLB06 was paid off in October 2026, so it is charged January-October and NOT November + (client ruling, 2026-11-05). The rule lives in `financeMonths()` in + `lib/running-costs.ts`: a LIVE agreement runs from its start through its paid-off date, + or through the current month when no paid-off date is on record; a SETTLED one is charged + only UP TO its paid-off date, and contributes NOTHING 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. Do not charge an agreement for the months after it ended, and do not drop the + months it was still paying. - **On a card the month figure sits BESIDE the year figure** (`md` and up) and gets its own line below `md` - the same shape the income cards use. The card's sparkline yields its width to the month figure below `xl`, because the vehicle NAME must not be truncated to diff --git a/app/(dashboard)/running/financing/page.tsx b/app/(dashboard)/running/financing/page.tsx index f874b83..ba5f763 100644 --- a/app/(dashboard)/running/financing/page.tsx +++ b/app/(dashboard)/running/financing/page.tsx @@ -45,7 +45,7 @@ export default async function RunningFinancingPage() { const a = byId.get(t.asset); return Boolean(a && a.code); }); - const fleetFinance = fleetFinanceByMonth(registerTerms, through); + const fleetFinance = fleetFinanceByMonth(registerTerms, through, YEAR); // A settled agreement has stopped being paid, so it must not sit in the run-rate. const monthInstalment = fleetFinance[monthIndex] ?? 0; const ytdInstalment = fleetFinance.slice(0, through).reduce((s, v) => s + v, 0); diff --git a/lib/financing.ts b/lib/financing.ts index d4de60f..9397da7 100644 --- a/lib/financing.ts +++ b/lib/financing.ts @@ -47,7 +47,7 @@ function monthIndex(year: number, month: number): number { } /** A month is only "set" when BOTH the year and the month are real (0 means unknown). */ -function hasMonth(year: number, month: number): boolean { +export function hasMonth(year: number, month: number): boolean { return year > 0 && month >= 1 && month <= 12; } @@ -121,19 +121,6 @@ export function financeCalc(input: FinanceInput): FinanceCalc { 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; diff --git a/lib/running-costs.ts b/lib/running-costs.ts index 135a458..de45091 100644 --- a/lib/running-costs.ts +++ b/lib/running-costs.ts @@ -1,5 +1,6 @@ import type { Asset, FinancingTerm, InsuranceRate, MonthlyRunningCost, Purchase, PurchaseLine } from "./data"; import { RUNNING_VAT_PCT, exclFromIncl } from "./purchase-booking"; +import { hasMonth } from "./financing"; export { RUNNING_VAT_PCT, exclFromIncl }; @@ -80,33 +81,74 @@ function poMonth(p: Purchase, year: number): number | null { * client's current sheet (18 agreements — R428 585,04/mo). Using both double-counts * the four shared machines, so the current sheet wins and the stale rows are ignored. */ +/** + * The months of `year` an agreement is charged for — 1-based months, so 1 = January. + * + * An agreement runs from its start to the month it is paid off, and those dates are + * typed in by a human (no sheet records them), so a month outside that window is a + * month the money was NOT moving: + * + * - LIVE (not settled): charged from its start through its paid-off date, or through + * the current month when no paid-off date is on record. + * - SETTLED: the money has stopped. It is still charged for the months UP TO its + * paid-off date — those instalments really were paid — and contributes NOTHING when + * there is no paid-off date on record, because there is then no month we can say it + * paid for. + * + * TLB06 is the case this exists for: settled, paid off in October 2026. It is charged + * January–October and NOT November (client ruling, 2026-11-05). + */ +export function financeMonths(t: FinancingTerm, year: number, horizon: number): number[] { + const first = year * 12 + 1; // January of `year`, as an absolute month number + const last = year * 12 + horizon; // the last month the Overview shows + const start = hasMonth(t.start_year, t.start_month) ? t.start_year * 12 + t.start_month : null; + const end = hasMonth(t.end_year, t.end_month) ? t.end_year * 12 + t.end_month : null; + // A settled agreement with no paid-off date: we cannot say when it stopped, so we do + // not claim any month for it. A machine that is not being financed is not a running + // cost. + if (t.is_settled && end === null) return []; + const from = start === null ? first : Math.max(start, first); + const to = end === null ? last : Math.min(end, last); + const months: number[] = []; + for (let m = from; m <= to; m++) months.push(m - year * 12); + return months; +} + /** * The finance cost per agreement, per month, up to the horizon. * - * A LIVE agreement is charged its instalment every month — it is a monthly commitment, - * so it recurs. A SETTLED agreement has stopped being paid, so it contributes nothing: - * a paid-off machine is not a running cost. - * - * The Overview and the financing register BOTH read this, so the fleet's finance for a + * The rule is `financeMonths`: an agreement is charged while it was live, and the + * Overview and the financing register BOTH read this, so the fleet's finance for a * month is the same figure on both screens and they cannot drift apart. */ -export function financeByAsset(terms: FinancingTerm[], horizon: number): Map { +export function financeByAsset( + terms: FinancingTerm[], + horizon: number, + year = 2026, +): Map { const out = new Map(); for (const t of terms) { - if (t.is_settled) continue; - for (let m = 1; m <= horizon; m++) add(out, t.asset, m, t.installment || 0); + if (!t.asset) continue; + const months = financeMonths(t, year, horizon); + if (!months.length) continue; + const arr = out.get(t.asset) || blank(); + for (const m of months) { + const at = arr[m - 1]; + arr[m - 1] = at == null ? (t.installment || 0) : at + (t.installment || 0); + } + out.set(t.asset, arr); } return out; } /** - * The fleet's finance cost per month — every live agreement's instalment, month by - * month. The financing register reads this for its own month and year-to-date figures, - * so the register and the Overview quote the same rands. + * The fleet's finance cost per month — every agreement's instalment for the months it + * was live. The financing register reads this for its own month and year-to-date + * figures, so the register and the Overview quote the same rands. */ -export function fleetFinanceByMonth(terms: FinancingTerm[], horizon: number): number[] { +export function fleetFinanceByMonth(terms: FinancingTerm[], horizon: number, year = 2026): number[] { const months = Array(12).fill(0); - for (const perAsset of financeByAsset(terms, horizon).values()) { + for (const perAsset of financeByAsset(terms, horizon, year).values()) { perAsset.forEach((v, i) => { if (v != null) months[i] += v; }); @@ -164,11 +206,12 @@ export function machineRunningCosts(input: RunningCostInputs, lastMonth?: number for (let m = rate.month_from; m <= end; m++) add(insurance, rate.asset, m, rate.rate); } - // 4. Finance — a MONTHLY instalment, so it recurs every month, and only while the - // agreement is live. Settled agreements have stopped, so they contribute nothing: - // a paid-off machine is not a running cost. The rule lives in `financeByAsset`, - // which the financing register reads too, so the two screens cannot disagree. - const finance = financeByAsset(input.finance, horizon); + // 4. Finance — a MONTHLY instalment, so it recurs every month the agreement was + // live. The rule lives in `financeMonths`: an agreement that was paid off in + // October is charged up to October and NOT in November, whether or not the + // settled flag is on. The financing register reads the same helper, so the two + // screens cannot disagree. + const finance = financeByAsset(input.finance, horizon, year); const byAsset = new Map(); const seen = new Set([...repairs.keys(), ...purchases.keys(), ...insurance.keys(), ...finance.keys()]); diff --git a/lib/running-month.test.ts b/lib/running-month.test.ts index 3dc9c4a..483493e 100644 --- a/lib/running-month.test.ts +++ b/lib/running-month.test.ts @@ -45,7 +45,12 @@ const rate = (assetId: string, from: number, to: number, amount: number) => rate: amount, }) as any; -const term = (assetId: string, installment: number, settled = false) => +const term = ( + assetId: string, + installment: number, + settled = false, + end: [number, number] = [2026, 12], +) => ({ id: `t-${assetId}`, asset: assetId, @@ -53,10 +58,13 @@ const term = (assetId: string, installment: number, settled = false) => is_settled: settled, start_year: 2026, start_month: 1, - end_year: 2026, - end_month: 12, + end_year: end[0], + end_month: end[1], }) as any; +/** Rands to the cent — the monthly figures are floats and must not be compared exactly. */ +const cents = (n: number) => Math.round(n * 100) / 100; + test("elapsed months count the year as it stands", () => { assert.equal(elapsedMonths(2026, new Date("2026-11-05T09:00:00")), 11); assert.equal(elapsedMonths(2026, new Date("2026-01-15T09:00:00")), 1); @@ -84,9 +92,11 @@ test("a month with no premium on record is unknown, not free", () => { assert.equal(rows[0].chargedMonths, 6); }); -test("a settled agreement costs nothing; a live one costs every month", () => { +test("a live agreement runs to the current month; a settled one with no paid-off date costs nothing", () => { const months = fleetFinanceByMonth( - [term("a1", 1000), term("a2", 500, true)], + // The settled one has NO paid-off date: we cannot say when it stopped, so it is + // charged for nothing at all. + [term("a1", 1000), term("a2", 500, true, [0, 0])], 11, ); assert.equal(months[0], 1000, "January: the live agreement only"); @@ -99,6 +109,59 @@ test("a settled agreement costs nothing; a live one costs every month", () => { ); }); +test("a settled agreement whose paid-off date is still ahead is still paying", () => { + // "Settled" means paid off, OR will be paid off by the paid-off date — so the money + // is still moving until then. + const months = fleetFinanceByMonth([term("a2", 500, true, [2026, 12])], 11); + assert.equal(months[10], 500, "November is still inside its window"); +}); + +test("an agreement paid off in October is charged to October and NOT to November", () => { + // TLB06: settled, paid off in October 2026. The instalments it was paying in + // January-October are real spend and belong in the year; November is not its month. + const tlb06 = { + id: "t-tlb06", + asset: "a1", + installment: 29036.08, + is_settled: true, + start_year: 2023, + start_month: 9, + end_year: 2026, + end_month: 10, + } as any; + const months = fleetFinanceByMonth([tlb06], 11); + assert.equal(months[0], 29036.08, "January is charged"); + assert.equal(months[8], 29036.08, "September is charged"); + assert.equal(months[9], 29036.08, "October is charged"); + assert.equal(months[10], 0, "NOVEMBER IS NOT — the agreement ended in October"); + assert.equal( + cents(months.slice(0, 11).reduce((s, v) => s + v, 0)), + cents(29036.08 * 10), + "ten months of instalments, not eleven", + ); +}); + +test("a live agreement stops at its own paid-off date", () => { + // RV04: paid off in November 2026, so it IS charged for November. + const rv04 = { + id: "t-rv04", + asset: "a1", + installment: 28641, + is_settled: false, + start_year: 2024, + start_month: 1, + end_year: 2026, + end_month: 11, + } as any; + const months = fleetFinanceByMonth([rv04], 11); + assert.equal(months[10], 28641, "November is its last month"); + assert.equal( + cents(months.slice(0, 11).reduce((s, v) => s + v, 0)), + cents(28641 * 11), + "charged for every month of the year it was live", + ); +}); + test("the register and the Overview read the same finance rule", () => { const terms = [term("a1", 1000)]; const perAsset = financeByAsset(terms, 11); @@ -108,6 +171,43 @@ test("the register and the Overview read the same finance rule", () => { assert.equal(fleetFinanceByMonth(terms, 11)[10], 1000); }); +test("the Overview's finance year to date IS the financing register's year to date", () => { + const assets = [asset("a1"), asset("a2", "TLB06")]; + const terms = [ + { + id: "t-tlb06", + asset: "a2", + installment: 29036.08, + is_settled: true, + start_year: 2023, + start_month: 9, + end_year: 2026, + end_month: 10, + } as any, + { + id: "t-rv04", + asset: "a1", + installment: 28641, + is_settled: false, + start_year: 2024, + start_month: 1, + end_year: 2026, + end_month: 11, + } as any, + ]; + + const { byAsset } = machineRunningCosts( + { assets, repairs: [], purchases: [], purchaseLines: [], insurance: [], finance: terms }, + 11, + ); + const running = buildRunningRows(assets, byAsset); + const overviewYtd = running.reduce((s, r) => s + r.byKindYtd.finance, 0); + const registerYtd = fleetFinanceByMonth(terms, 11).slice(0, 11).reduce((s, v) => s + v, 0); + + assert.equal(cents(overviewYtd), cents(registerYtd), "the two screens must quote the same rands"); + assert.equal(cents(overviewYtd), cents(29036.08 * 10 + 28641 * 11)); +}); + test("the insurance page and the Overview quote the same year to date", () => { const assets = [asset("a1"), asset("a2", "TLB02")]; const rates = [rate("a1", 1, 0, 1300), rate("a2", 1, 6, 900), rate("a2", 7, 0, 1000)];