The "actual vs actual" compare was expenses-only (WHERE amount < 0), so revenue categories were invisible — you could not tell a surplus from a deficit. Make it an income statement (the product's namesake "résultat"): - COMPARE_DELTA_SQL: broaden the WHERE and per-type CASE to include `income` as a signed SUM (like `transfer`), so revenue credits survive the outflow filter. Expenses stay ABS-of-outflows, byte-identical. Transfer netting (#243) intact. - Section order is now income -> expense -> transfer (COMPARE_TYPE_ORDER). - ComparePeriodTable: replace the now-meaningless flat grand total with two result lines — "result before transfers" (revenues - expenses) and the net "result" (after transfers) — extracted into a pure, tested compareResults module. Delta colours are direction-aware (income/result up = green, spending up = red); result amounts are coloured by sign (surplus/deficit). - ComparePeriodChart stays a spending view: filter to expense leaves so revenue bars don't mix into the same axis. - useCompare: skip the initial period-sync so the compare opens on the previous (last complete) month instead of the civil-year December that useReportsPeriod yields by default. Also anchor the `reports/` gitignore to `/reports/` — the unanchored rule was silently ignoring new files under src/components/reports/. Tests: new compareResults.test.ts (result roll-up, unbalanced transfer, deficit, subtotal exclusion); reportService.test.ts updated for the broadened SQL, the income bucket rule, and income-first section order. Build + 685 vitest green. Resolves #253 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
87 lines
3 KiB
TypeScript
87 lines
3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import type { CategoryDelta } from "../../shared/types";
|
||
import { computeResults, sumLeaves } from "./compareResults";
|
||
|
||
// Minimal leaf factory. Amounts arrive from COMPARE_DELTA_SQL already in the
|
||
// per-type sign convention (expenses = positive ABS, income/transfer = signed).
|
||
function leaf(
|
||
type: "expense" | "income" | "transfer",
|
||
mc: number,
|
||
mp: number,
|
||
cc = mc,
|
||
cp = mp,
|
||
): CategoryDelta {
|
||
return {
|
||
categoryId: 1,
|
||
categoryName: type,
|
||
categoryColor: "#000",
|
||
previousAmount: mp,
|
||
currentAmount: mc,
|
||
deltaAbs: mc - mp,
|
||
deltaPct: mp !== 0 ? ((mc - mp) / mp) * 100 : null,
|
||
cumulativePreviousAmount: cp,
|
||
cumulativeCurrentAmount: cc,
|
||
cumulativeDeltaAbs: cc - cp,
|
||
cumulativeDeltaPct: cp !== 0 ? ((cc - cp) / cp) * 100 : null,
|
||
category_type: type,
|
||
is_parent: false,
|
||
};
|
||
}
|
||
|
||
describe("computeResults — income-statement roll-up (Issue #253)", () => {
|
||
it("resultBefore = revenues − expenses; resultNet adds net transfers", () => {
|
||
const r = computeResults([
|
||
leaf("income", 4350, 4500),
|
||
leaf("expense", 3900, 3700),
|
||
leaf("transfer", 0, 0),
|
||
]);
|
||
// Operating result: 4350 − 3900 = 450 current; 4500 − 3700 = 800 previous.
|
||
expect(r.resultBefore.monthCurrent).toBe(450);
|
||
expect(r.resultBefore.monthPrevious).toBe(800);
|
||
expect(r.resultBefore.monthDelta).toBe(-350);
|
||
// Balanced transfer (0) → net equals the operating result.
|
||
expect(r.resultNet.monthCurrent).toBe(450);
|
||
expect(r.hasTransfers).toBe(true);
|
||
});
|
||
|
||
it("an unbalanced (one-leg) transfer moves resultNet away from resultBefore", () => {
|
||
const r = computeResults([
|
||
leaf("income", 4000, 4000),
|
||
leaf("expense", 3000, 3000),
|
||
leaf("transfer", -200, 0), // a single categorised leg
|
||
]);
|
||
expect(r.resultBefore.monthCurrent).toBe(1000);
|
||
// Net folds the residual transfer in: 1000 + (−200) = 800.
|
||
expect(r.resultNet.monthCurrent).toBe(800);
|
||
});
|
||
|
||
it("a deficit yields a negative net result and no transfer line", () => {
|
||
const r = computeResults([leaf("income", 2000, 2000), leaf("expense", 2500, 2400)]);
|
||
expect(r.resultNet.monthCurrent).toBe(-500);
|
||
expect(r.hasTransfers).toBe(false);
|
||
});
|
||
|
||
it("ignores subtotal (is_parent) rows so a group is not double-counted", () => {
|
||
const parent = { ...leaf("expense", 999, 999), is_parent: true };
|
||
const r = computeResults([parent, leaf("expense", 100, 80)]);
|
||
expect(r.expense.monthCurrent).toBe(100);
|
||
});
|
||
|
||
it("carries the YTD figures through the same arithmetic", () => {
|
||
const r = computeResults([
|
||
leaf("income", 100, 100, 1200, 1150),
|
||
leaf("expense", 60, 60, 700, 650),
|
||
]);
|
||
expect(r.resultBefore.ytdCurrent).toBe(500);
|
||
expect(r.resultBefore.ytdPrevious).toBe(500);
|
||
});
|
||
|
||
it("sumLeaves adds only leaves", () => {
|
||
const t = sumLeaves([
|
||
leaf("expense", 100, 50),
|
||
{ ...leaf("expense", 999, 999), is_parent: true },
|
||
]);
|
||
expect(t.monthCurrent).toBe(100);
|
||
expect(t.monthPrevious).toBe(50);
|
||
});
|
||
});
|