fix(reports): exclude income from Cartes top movers (#253 regression)
All checks were successful
PR Check / rust (pull_request) Successful in 22m23s
PR Check / frontend (pull_request) Successful in 2m25s

/pr-review on #255 caught a cross-consumer regression: broadening
COMPARE_DELTA_SQL to surface income (this PR) also feeds getCartesSnapshot,
whose "top movers" card is a spending view (up = red). A salary rise would land
under "biggest increases" coloured red — inverted meaning.

Filter significantMovers to expense leaves ((category_type ?? "expense") ===
"expense"), mirroring ComparePeriodChart. The surviving expense output is
byte-identical to pre-#253. Add a Cartes regression test (an income category
with the biggest delta must not top the movers list) — the existing test mocked
the compare SQL without category types, so it stayed silently green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-05 20:14:23 -04:00
parent d57b2563af
commit c7914c2ca5
2 changed files with 40 additions and 5 deletions

View file

@ -235,6 +235,33 @@ describe("getCartesSnapshot", () => {
expect(snapshot.topMoversDown[0].categoryName).toBe("D3"); expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
}); });
it("excludes income from top movers so a salary rise is not a red 'increase' (#253)", async () => {
// Post-#253 COMPARE_DELTA_SQL surfaces income as a signed positive SUM, so
// revenue rows now reach getCartesSnapshot. Top movers is a spending view
// (up = red), so income must be filtered out — otherwise a salary jump tops
// "biggest increases" in red. The category metadata query types id 99 as
// income; the two expense movers are absent from it and default to expense.
const momRows = [
{ category_id: 99, category_name: "Salaire", category_color: "#000", month_current_total: 5000, month_previous_total: 100, cumulative_current_total: 5000, cumulative_previous_total: 100 },
{ category_id: 1, category_name: "Épicerie", category_color: "#000", month_current_total: 300, month_previous_total: 100, cumulative_current_total: 300, cumulative_previous_total: 100 },
{ category_id: 2, category_name: "Resto", category_color: "#000", month_current_total: 100, month_previous_total: 400, cumulative_current_total: 100, cumulative_previous_total: 400 },
];
routeSelect([
{ match: "strftime('%Y-%m', date)", rows: [{ month: "2026-03", income: 5000, expenses: 400 }] },
{ match: "ORDER BY ABS(month_current_total - month_previous_total) DESC", rows: momRows },
{ match: "parent_id FROM categories", rows: [{ id: 99, name: "Salaire", color: null, type: "income", parent_id: null }] },
]);
const snapshot = await getCartesSnapshot(2026, 3);
const upNames = snapshot.topMoversUp.map((m) => m.categoryName);
// Income excluded despite the biggest delta (+4900).
expect(upNames).not.toContain("Salaire");
// The biggest EXPENSE increase leads instead.
expect(snapshot.topMoversUp[0].categoryName).toBe("Épicerie");
// Expense decrease still surfaces.
expect(snapshot.topMoversDown[0].categoryName).toBe("Resto");
});
it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => { it("computes YTD KPIs correctly when mode=ytd (sums Jan→refMonth of refYear)", async () => {
// Reference = 2026-03, YTD = Jan + Feb + Mar of 2026. // Reference = 2026-03, YTD = Jan + Feb + Mar of 2026.
routeSelect([ routeSelect([

View file

@ -1170,12 +1170,20 @@ export async function getCartesSnapshot(
// Top movers: biggest MoM increases / decreases. `momRows` now carries the // Top movers: biggest MoM increases / decreases. `momRows` now carries the
// compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a // compare hierarchy (Issue #247) — skip the subtotal (`is_parent`) rows so a
// parent group can't double-count against its own leaves. The surviving leaves // parent group can't double-count against its own leaves. Keep only EXPENSE
// are byte-identical to the previous flat output; the sort/slice below is // leaves: since Issue #253 broadened COMPARE_DELTA_SQL to surface income (and
// unchanged. `momRows` are sorted by absolute delta already; filter out // net transfers), momRows now also carries revenue rows — and this card is a
// near-zero noise and split by sign. // spending view whose colours read "up = red" (more spending is bad), so a
// salary rise must not appear under "biggest increases" in red. Mirror the
// expense-only filter ComparePeriodChart uses. The surviving expense leaves
// are byte-identical to the pre-#253 flat output. `momRows` are sorted by
// absolute delta already; filter out near-zero noise and split by sign.
const significantMovers = momRows.filter( const significantMovers = momRows.filter(
(r) => !r.is_parent && r.deltaAbs !== 0 && (r.previousAmount > 0 || r.currentAmount > 0), (r) =>
!r.is_parent &&
(r.category_type ?? "expense") === "expense" &&
r.deltaAbs !== 0 &&
(r.previousAmount > 0 || r.currentAmount > 0),
); );
// Project the richer CategoryDelta shape down to the narrower CartesTopMover // Project the richer CategoryDelta shape down to the narrower CartesTopMover
// shape so the Cartes dashboard keeps its stable contract regardless of how // shape so the Cartes dashboard keeps its stable contract regardless of how