Simpl-Resultat/src/components/reports/compareResults.test.ts
le king fu f45845e408
All checks were successful
PR Check / rust (pull_request) Successful in 23m4s
PR Check / frontend (pull_request) Successful in 2m26s
feat(reports): real-vs-real compare as an income statement + previous-month default
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>
2026-07-05 18:36:57 -04:00

87 lines
3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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