Adds a segmented Monthly/YTD toggle next to the reference-month picker that flips the four KPI cards (income, expenses, net, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January to the reference month of the reference year; MoM delta compares it to Jan to (refMonth - 1) of the same year (null in January, since no prior YTD window exists); YoY delta compares it to Jan to refMonth of the previous year; savings rate is recomputed from YTD income and expenses, and stays null when YTD income is zero. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle (by design). The savings-rate tooltip is now dynamic and mirrors the active mode. The mode is persisted in localStorage under `reports-cartes-period-mode`. Also adds a dedicated Cartes section to `docs/guide-utilisateur.md` covering the four KPI formulas, the Monthly/YTD toggle and its effect on deltas, the sparkline, top movers, seasonality, budget adherence and the savings-rate edge case. Mirrored in the in-app `docs.reports` i18n tree (features/steps/ tips extended) for both FR and EN. No SQL migration: YTD sums are computed from the already-fetched `flowByMonth` map, so no extra round trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { shiftMonth, getCartesSnapshot } from "./reportService";
|
|
|
|
vi.mock("./db", () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
|
|
import { getDb } from "./db";
|
|
|
|
const mockSelect = vi.fn();
|
|
const mockDb = { select: mockSelect };
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
|
mockSelect.mockReset();
|
|
});
|
|
|
|
describe("shiftMonth", () => {
|
|
it("shifts forward within a year", () => {
|
|
expect(shiftMonth(2026, 1, 2)).toEqual({ year: 2026, month: 3 });
|
|
});
|
|
|
|
it("shifts backward within a year", () => {
|
|
expect(shiftMonth(2026, 6, -3)).toEqual({ year: 2026, month: 3 });
|
|
});
|
|
|
|
it("wraps around January to the previous year", () => {
|
|
expect(shiftMonth(2026, 1, -1)).toEqual({ year: 2025, month: 12 });
|
|
});
|
|
|
|
it("wraps past multiple years back", () => {
|
|
expect(shiftMonth(2026, 4, -24)).toEqual({ year: 2024, month: 4 });
|
|
});
|
|
|
|
it("wraps past year forward", () => {
|
|
expect(shiftMonth(2025, 11, 3)).toEqual({ year: 2026, month: 2 });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Dispatch mock SELECT responses based on the SQL fragment being queried.
|
|
* Each entry returns the canned rows for queries whose text contains `match`.
|
|
*/
|
|
function routeSelect(routes: { match: string; rows: unknown[] }[]): void {
|
|
mockSelect.mockImplementation((sql: string) => {
|
|
for (const r of routes) {
|
|
if (sql.includes(r.match)) return Promise.resolve(r.rows);
|
|
}
|
|
return Promise.resolve([]);
|
|
});
|
|
}
|
|
|
|
describe("getCartesSnapshot", () => {
|
|
it("returns zero-filled KPIs when there is no data", async () => {
|
|
routeSelect([]);
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
expect(snapshot.referenceYear).toBe(2026);
|
|
expect(snapshot.referenceMonth).toBe(3);
|
|
expect(snapshot.kpis.income.current).toBe(0);
|
|
expect(snapshot.kpis.expenses.current).toBe(0);
|
|
expect(snapshot.kpis.net.current).toBe(0);
|
|
// Savings rate is null (renders as "—") when income is zero.
|
|
expect(snapshot.kpis.savingsRate.current).toBeNull();
|
|
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
|
|
expect(snapshot.flow12Months).toHaveLength(12);
|
|
expect(snapshot.topMoversUp).toHaveLength(0);
|
|
expect(snapshot.topMoversDown).toHaveLength(0);
|
|
expect(snapshot.budgetAdherence.categoriesTotal).toBe(0);
|
|
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
|
|
expect(snapshot.seasonality.historicalAverage).toBeNull();
|
|
expect(snapshot.seasonality.deviationPct).toBeNull();
|
|
});
|
|
|
|
it("computes MoM and YoY deltas from a monthly flow stream", async () => {
|
|
// Reference = 2026-03
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2025-03", income: 3000, expenses: 1800 }, // YoY comparison
|
|
{ month: "2026-02", income: 4000, expenses: 2000 }, // MoM comparison
|
|
{ month: "2026-03", income: 5000, expenses: 2500 }, // Reference
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
|
|
expect(snapshot.kpis.income.current).toBe(5000);
|
|
expect(snapshot.kpis.income.previousMonth).toBe(4000);
|
|
expect(snapshot.kpis.income.previousYear).toBe(3000);
|
|
expect(snapshot.kpis.income.deltaMoMAbs).toBe(1000);
|
|
expect(snapshot.kpis.income.deltaMoMPct).toBe(25);
|
|
expect(snapshot.kpis.income.deltaYoYAbs).toBe(2000);
|
|
expect(Math.round(snapshot.kpis.income.deltaYoYPct ?? 0)).toBe(67);
|
|
|
|
expect(snapshot.kpis.expenses.current).toBe(2500);
|
|
expect(snapshot.kpis.net.current).toBe(2500);
|
|
expect(snapshot.kpis.savingsRate.current).toBe(50);
|
|
});
|
|
|
|
it("January reference month shifts MoM to December of previous year", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2025-12", income: 2000, expenses: 1000 },
|
|
{ month: "2026-01", income: 3000, expenses: 1500 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 1);
|
|
|
|
expect(snapshot.kpis.income.current).toBe(3000);
|
|
expect(snapshot.kpis.income.previousMonth).toBe(2000);
|
|
// YoY for January 2026 = January 2025 = no data
|
|
expect(snapshot.kpis.income.previousYear).toBeNull();
|
|
expect(snapshot.kpis.income.deltaYoYAbs).toBeNull();
|
|
});
|
|
|
|
it("savings rate is null when income is zero (no division by zero, renders as — in UI)", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2026-03", income: 0, expenses: 500 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
expect(snapshot.kpis.savingsRate.current).toBeNull();
|
|
expect(snapshot.kpis.income.current).toBe(0);
|
|
expect(snapshot.kpis.expenses.current).toBe(500);
|
|
expect(snapshot.kpis.net.current).toBe(-500);
|
|
});
|
|
|
|
it("handles less than 13 months of history by filling gaps with zero", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2026-03", income: 1000, expenses: 400 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
expect(snapshot.kpis.income.sparkline).toHaveLength(13);
|
|
// First 12 points are zero, last one is 1000
|
|
expect(snapshot.kpis.income.sparkline[12].value).toBe(1000);
|
|
expect(snapshot.kpis.income.sparkline[0].value).toBe(0);
|
|
// MoM comparison with a missing month returns null (no data for 2026-02)
|
|
expect(snapshot.kpis.income.previousMonth).toBeNull();
|
|
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
|
|
});
|
|
|
|
it("computes seasonality only when historical data exists", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [{ month: "2026-03", income: 3000, expenses: 1500 }],
|
|
},
|
|
{
|
|
match: "CAST(strftime('%Y', date) AS INTEGER) AS year",
|
|
rows: [
|
|
{ year: 2025, amount: 1200 },
|
|
{ year: 2024, amount: 1000 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
expect(snapshot.seasonality.historicalYears).toHaveLength(2);
|
|
expect(snapshot.seasonality.historicalAverage).toBe(1100);
|
|
expect(snapshot.seasonality.referenceAmount).toBe(1500);
|
|
// (1500 - 1100) / 1100 * 100 ≈ 36.36
|
|
expect(Math.round(snapshot.seasonality.deviationPct ?? 0)).toBe(36);
|
|
});
|
|
|
|
it("seasonality deviation stays null when there is no historical average", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [{ month: "2026-03", income: 2000, expenses: 800 }],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
expect(snapshot.seasonality.historicalYears).toHaveLength(0);
|
|
expect(snapshot.seasonality.historicalAverage).toBeNull();
|
|
expect(snapshot.seasonality.deviationPct).toBeNull();
|
|
});
|
|
|
|
it("splits top movers by sign and caps each list at 5", async () => {
|
|
// Seven up-movers, three down-movers — verify we get 5 up and 3 down.
|
|
// Cumulative totals mirror the monthly ones in this fixture — the Cartes
|
|
// service only reads the monthly block for top movers, so cumulative
|
|
// values are inert here.
|
|
const momRows = [
|
|
{ category_id: 1, category_name: "C1", category_color: "#000", month_current_total: 200, month_previous_total: 100, cumulative_current_total: 200, cumulative_previous_total: 100 },
|
|
{ category_id: 2, category_name: "C2", category_color: "#000", month_current_total: 400, month_previous_total: 100, cumulative_current_total: 400, cumulative_previous_total: 100 },
|
|
{ category_id: 3, category_name: "C3", category_color: "#000", month_current_total: 500, month_previous_total: 100, cumulative_current_total: 500, cumulative_previous_total: 100 },
|
|
{ category_id: 4, category_name: "C4", category_color: "#000", month_current_total: 700, month_previous_total: 100, cumulative_current_total: 700, cumulative_previous_total: 100 },
|
|
{ category_id: 5, category_name: "C5", category_color: "#000", month_current_total: 900, month_previous_total: 100, cumulative_current_total: 900, cumulative_previous_total: 100 },
|
|
{ category_id: 6, category_name: "C6", category_color: "#000", month_current_total: 1100, month_previous_total: 100, cumulative_current_total: 1100, cumulative_previous_total: 100 },
|
|
{ category_id: 7, category_name: "C7", category_color: "#000", month_current_total: 1300, month_previous_total: 100, cumulative_current_total: 1300, cumulative_previous_total: 100 },
|
|
{ category_id: 8, category_name: "D1", category_color: "#000", month_current_total: 100, month_previous_total: 500, cumulative_current_total: 100, cumulative_previous_total: 500 },
|
|
{ category_id: 9, category_name: "D2", category_color: "#000", month_current_total: 100, month_previous_total: 700, cumulative_current_total: 100, cumulative_previous_total: 700 },
|
|
{ category_id: 10, category_name: "D3", category_color: "#000", month_current_total: 100, month_previous_total: 900, cumulative_current_total: 100, cumulative_previous_total: 900 },
|
|
];
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [{ month: "2026-03", income: 1000, expenses: 500 }],
|
|
},
|
|
{
|
|
// Matches the getCompareMonthOverMonth SQL pattern.
|
|
match: "ORDER BY ABS(month_current_total - month_previous_total) DESC",
|
|
rows: momRows,
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
expect(snapshot.topMoversUp).toHaveLength(5);
|
|
expect(snapshot.topMoversDown).toHaveLength(3);
|
|
// Top up is the biggest delta (C7: +1200)
|
|
expect(snapshot.topMoversUp[0].categoryName).toBe("C7");
|
|
// Top down is the biggest negative delta (D3: -800)
|
|
expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
|
|
});
|
|
|
|
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
|
|
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
// Previous year YTD: Jan + Feb + Mar 2025 = income 3000, expenses 1500.
|
|
{ month: "2025-01", income: 1000, expenses: 500 },
|
|
{ month: "2025-02", income: 1000, expenses: 500 },
|
|
{ month: "2025-03", income: 1000, expenses: 500 },
|
|
// Current YTD: Jan + Feb + Mar 2026.
|
|
{ month: "2026-01", income: 2000, expenses: 800 },
|
|
{ month: "2026-02", income: 2500, expenses: 1000 },
|
|
{ month: "2026-03", income: 3000, expenses: 1200 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3, "ytd");
|
|
|
|
// Current = sum Jan+Feb+Mar 2026
|
|
expect(snapshot.kpis.income.current).toBe(7500);
|
|
expect(snapshot.kpis.expenses.current).toBe(3000);
|
|
expect(snapshot.kpis.net.current).toBe(4500);
|
|
// Savings rate YTD = 4500 / 7500 = 60%
|
|
expect(snapshot.kpis.savingsRate.current).toBe(60);
|
|
|
|
// MoM in YTD = current YTD vs Jan→Feb 2026 = income 4500, expenses 1800.
|
|
expect(snapshot.kpis.income.previousMonth).toBe(4500);
|
|
expect(snapshot.kpis.income.deltaMoMAbs).toBe(3000);
|
|
expect(snapshot.kpis.expenses.previousMonth).toBe(1800);
|
|
|
|
// YoY in YTD = Jan→Mar 2026 vs Jan→Mar 2025.
|
|
expect(snapshot.kpis.income.previousYear).toBe(3000);
|
|
expect(snapshot.kpis.income.deltaYoYAbs).toBe(4500);
|
|
expect(snapshot.kpis.expenses.previousYear).toBe(1500);
|
|
});
|
|
|
|
it("YTD savings rate is null when YTD income is zero", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2026-01", income: 0, expenses: 200 },
|
|
{ month: "2026-02", income: 0, expenses: 150 },
|
|
{ month: "2026-03", income: 0, expenses: 300 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3, "ytd");
|
|
expect(snapshot.kpis.income.current).toBe(0);
|
|
expect(snapshot.kpis.expenses.current).toBe(650);
|
|
expect(snapshot.kpis.savingsRate.current).toBeNull();
|
|
});
|
|
|
|
it("YTD MoM delta is null when reference month is January (no prior YTD window in same year)", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2025-01", income: 500, expenses: 200 },
|
|
{ month: "2026-01", income: 1000, expenses: 400 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 1, "ytd");
|
|
|
|
expect(snapshot.kpis.income.current).toBe(1000);
|
|
// MoM previous YTD for January is empty (no prior month in same year).
|
|
expect(snapshot.kpis.income.previousMonth).toBeNull();
|
|
expect(snapshot.kpis.income.deltaMoMAbs).toBeNull();
|
|
expect(snapshot.kpis.income.deltaMoMPct).toBeNull();
|
|
// YoY still works — Jan 2025.
|
|
expect(snapshot.kpis.income.previousYear).toBe(500);
|
|
expect(snapshot.kpis.income.deltaYoYAbs).toBe(500);
|
|
});
|
|
|
|
it("YTD YoY delta uses Jan→refMonth of the previous year", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
// Prev year YTD Jan→Apr 2025
|
|
{ month: "2025-01", income: 100, expenses: 50 },
|
|
{ month: "2025-02", income: 100, expenses: 50 },
|
|
{ month: "2025-03", income: 100, expenses: 50 },
|
|
{ month: "2025-04", income: 100, expenses: 50 },
|
|
// Prev year outside window — must be ignored.
|
|
{ month: "2025-05", income: 999, expenses: 999 },
|
|
// Current YTD Jan→Apr 2026
|
|
{ month: "2026-01", income: 300, expenses: 100 },
|
|
{ month: "2026-02", income: 300, expenses: 100 },
|
|
{ month: "2026-03", income: 300, expenses: 100 },
|
|
{ month: "2026-04", income: 300, expenses: 100 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 4, "ytd");
|
|
|
|
expect(snapshot.kpis.income.current).toBe(1200);
|
|
expect(snapshot.kpis.income.previousYear).toBe(400); // 4 * 100
|
|
expect(snapshot.kpis.expenses.previousYear).toBe(200); // 4 * 50
|
|
expect(snapshot.kpis.income.deltaYoYAbs).toBe(800);
|
|
});
|
|
|
|
it("defaults to mode=month and produces monthly KPIs (back-compat)", async () => {
|
|
routeSelect([
|
|
{
|
|
match: "strftime('%Y-%m', date)",
|
|
rows: [
|
|
{ month: "2026-01", income: 1000, expenses: 400 },
|
|
{ month: "2026-02", income: 1000, expenses: 400 },
|
|
{ month: "2026-03", income: 5000, expenses: 2500 },
|
|
],
|
|
},
|
|
]);
|
|
|
|
// No mode argument — default "month".
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
// Monthly ref only, not YTD sum.
|
|
expect(snapshot.kpis.income.current).toBe(5000);
|
|
expect(snapshot.kpis.expenses.current).toBe(2500);
|
|
});
|
|
|
|
it("counts budgeted expense categories even though monthBudget is stored signed-negative (#112)", async () => {
|
|
// Regression: before the fix, `r.monthBudget > 0` rejected every expense
|
|
// row because budgetService signs expense budgets as negative. The card
|
|
// then claimed "no budgeted categories" even when the user had set
|
|
// budgets on expense categories.
|
|
routeSelect([
|
|
{
|
|
match: "FROM categories WHERE is_active = 1 ORDER BY sort_order",
|
|
rows: [
|
|
{
|
|
id: 10,
|
|
name: "Alimentation",
|
|
parent_id: null,
|
|
color: "#f59e0b",
|
|
icon: null,
|
|
type: "expense",
|
|
is_active: 1,
|
|
is_inputable: 1,
|
|
sort_order: 1,
|
|
created_at: "",
|
|
},
|
|
{
|
|
id: 11,
|
|
name: "Restaurants",
|
|
parent_id: null,
|
|
color: "#ef4444",
|
|
icon: null,
|
|
type: "expense",
|
|
is_active: 1,
|
|
is_inputable: 1,
|
|
sort_order: 2,
|
|
created_at: "",
|
|
},
|
|
{
|
|
id: 12,
|
|
name: "Loyer",
|
|
parent_id: null,
|
|
color: "#6366f1",
|
|
icon: null,
|
|
type: "expense",
|
|
is_active: 1,
|
|
is_inputable: 1,
|
|
sort_order: 3,
|
|
created_at: "",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
match: "FROM budget_entries WHERE year",
|
|
rows: [
|
|
{ id: 1, category_id: 10, year: 2026, month: 3, amount: 500 },
|
|
{ id: 2, category_id: 11, year: 2026, month: 3, amount: 200 },
|
|
// Category 12 has no budget entry.
|
|
],
|
|
},
|
|
{
|
|
// Matches both the monthly and YTD actuals queries; for this test
|
|
// the YTD slice can mirror the monthly one.
|
|
match: "FROM transactions\n WHERE date BETWEEN",
|
|
rows: [
|
|
{ category_id: 10, actual: -400 }, // 400 spent against 500 budget -> in target
|
|
{ category_id: 11, actual: -350 }, // 350 spent against 200 budget -> overrun
|
|
{ category_id: 12, actual: -150 }, // no budget, should not appear
|
|
],
|
|
},
|
|
]);
|
|
|
|
const snapshot = await getCartesSnapshot(2026, 3);
|
|
|
|
expect(snapshot.budgetAdherence.categoriesTotal).toBe(2);
|
|
expect(snapshot.budgetAdherence.categoriesInTarget).toBe(1);
|
|
expect(snapshot.budgetAdherence.worstOverruns).toHaveLength(1);
|
|
|
|
const [worst] = snapshot.budgetAdherence.worstOverruns;
|
|
expect(worst.categoryName).toBe("Restaurants");
|
|
expect(worst.actual).toBe(350);
|
|
expect(worst.budget).toBe(200);
|
|
expect(worst.overrunAbs).toBe(150);
|
|
expect(worst.overrunPct).toBeCloseTo(75, 5);
|
|
});
|
|
});
|