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.
This commit is contained in:
Dave 2026-11-05 01:56:26 +02:00
parent 5634c067c4
commit 72900878ba
5 changed files with 180 additions and 44 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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<string, (number | null)[]> {
export function financeByAsset(
terms: FinancingTerm[],
horizon: number,
year = 2026,
): Map<string, (number | null)[]> {
const out = new Map<string, (number | null)[]>();
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<string, MachineCosts>();
const seen = new Set([...repairs.keys(), ...purchases.keys(), ...insurance.keys(), ...finance.keys()]);

View file

@ -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)];