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:
parent
5634c067c4
commit
72900878ba
5 changed files with 180 additions and 44 deletions
18
AGENTS.md
18
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
Loading…
Reference in a new issue