diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 2ced1e7..7b50a23 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -7,6 +7,10 @@ - Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238). - Épinglé la dépendance transitive de build `@babel/core` en 7.29.7 via une entrée `overrides` scopée (sous `@vitejs/plugin-react`), corrigeant le dernier advisory `npm audit` (GHSA-4x5r-pxfx-6jf8, lecture de fichier arbitraire via un commentaire `sourceMappingURL`, sévérité faible). Outillage de build uniquement — aucun changement runtime ni de comportement — et `npm audit` est désormais propre (#241). +### Corrigé + +- Rapports → Comparaison (réel vs réel) : les catégories de type transfert (ex. « Paiement CC ») s'annulent maintenant à ~0 au lieu d'afficher seulement leurs sorties. Un transfert est un mouvement d'argent, pas une dépense — son débit et son crédit correspondant sur la même période se compensent. Les catégories de dépense et de revenu conservent exactement leurs montants précédents (#243). + ## [0.10.1] - 2026-06-30 ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 9442e91..39d567e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238). - Pinned the build-time transitive dependency `@babel/core` to 7.29.7 through a scoped `overrides` entry (under `@vitejs/plugin-react`), clearing the last `npm audit` advisory (GHSA-4x5r-pxfx-6jf8, arbitrary file read via a `sourceMappingURL` comment, low severity). Build tooling only — no runtime or behaviour change — and `npm audit` is now clean (#241). +### Fixed + +- Reports → Compare (real vs real): transfer-type categories (e.g. "Paiement CC") now net to ~0 instead of showing only their outflows. A transfer is a money move, not spending — its debit and matching credit over the same period cancel out. Expense and income categories keep exactly their previous amounts (#243). + ## [0.10.1] - 2026-06-30 ### Fixed diff --git a/src/services/reportService.test.ts b/src/services/reportService.test.ts index f84911f..25bc8d8 100644 --- a/src/services/reportService.test.ts +++ b/src/services/reportService.test.ts @@ -476,6 +476,117 @@ describe("getCompareYearOverYear", () => { }); }); +describe("real-vs-real compare — transfer netting (Issue #243)", () => { + // A `transfer` category (e.g. "Paiement CC") is a money move, not spending: + // its debit and matching credit must cancel to ~0 in the real-vs-real + // compare. Ordinary categories keep the expense-report behavior (only + // outflows, summed as positive magnitudes). The netting lives entirely in + // the SQL (COMPARE_DELTA_SQL): a signed SUM(t.amount) for `transfer`, + // ABS(t.amount) filtered on amount < 0 for everything else, with the WHERE + // broadened so transfer credits are not dropped before they can cancel. + // + // There is no runnable SQLite in these unit tests (tauri-plugin-sql only + // exists inside the Tauri WebView, and CI runs Node 20 → no node:sqlite), so + // the netting is pinned two ways: (1) a reference-semantics check over + // concrete debit/credit rows that reproduces the exact per-row rule the SQL + // encodes, and (2) structural assertions that the real query on BOTH compare + // functions carries that rule and the broadened WHERE. + + /** Per-row contribution to a bucket — the exact rule COMPARE_DELTA_SQL encodes. */ + function bucketContribution(amount: number, type: string | null): number { + return (type ?? "expense") === "transfer" + ? amount // signed → a debit and its matching credit cancel + : amount < 0 + ? Math.abs(amount) // expense outflow as a positive magnitude + : 0; // non-transfer credits dropped → no revenues surfaced + } + + it("nets a balanced transfer to ~0 while an expense keeps its sum of outflows", () => { + // "Paiement CC" (transfer): -500 out of chequing, +500 onto the card. + const transfer = [-500, 500].reduce((s, a) => s + bucketContribution(a, "transfer"), 0); + expect(transfer).toBeCloseTo(0, 6); + + // "Épicerie" (expense): two debits + one refund credit that must NOT net it down. + const expense = [-120, -80, 60].reduce((s, a) => s + bucketContribution(a, "expense"), 0); + expect(expense).toBe(200); + + // Uncategorized (c.type NULL) defaults to expense behavior. + const uncategorized = [-40, 15].reduce((s, a) => s + bucketContribution(a, null), 0); + expect(uncategorized).toBe(40); + }); + + it("getCompareMonthOverMonth SQL nets transfers (signed) but keeps ABS for other types", async () => { + mockSelect.mockResolvedValueOnce([]); + + await getCompareMonthOverMonth(2026, 4); + + const sql = mockSelect.mock.calls[0][0] as string; + // transfer → signed t.amount; every other type → ABS(t.amount) + expect(sql).toContain( + "COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)", + ); + // WHERE broadened so transfer credits survive the expense (amount < 0) filter + expect(sql).toContain( + "WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')", + ); + // still four date buckets + expect(sql).toContain("month_current_total"); + expect(sql).toContain("cumulative_previous_total"); + }); + + it("getCompareYearOverYear applies the identical netting contract (both tabs fixed)", async () => { + mockSelect.mockResolvedValueOnce([]); + + await getCompareYearOverYear(2026, 4); + + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain( + "COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount)", + ); + expect(sql).toContain( + "WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer')", + ); + }); + + it("keeps a netted transfer at 0 through the pipeline while an expense is unchanged", async () => { + // Rows as the netted SQL returns them: the balanced transfer arrives at 0, + // the ordinary expense keeps its outflow sum. Assert nothing downstream + // re-inflates the transfer. + mockSelect.mockResolvedValueOnce([ + { + category_id: 5, + category_name: "Épicerie", + category_color: "#10b981", + month_current_total: 200, + month_previous_total: 150, + cumulative_current_total: 800, + cumulative_previous_total: 600, + }, + { + category_id: 71, + category_name: "Paiement CC", + category_color: "#9ca3af", + month_current_total: 0, + month_previous_total: 0, + cumulative_current_total: 0, + cumulative_previous_total: 0, + }, + ]); + + const result = await getCompareMonthOverMonth(2026, 4); + const transferRow = result.find((r) => r.categoryName === "Paiement CC")!; + const expenseRow = result.find((r) => r.categoryName === "Épicerie")!; + + expect(transferRow.currentAmount).toBe(0); + expect(transferRow.previousAmount).toBe(0); + expect(transferRow.deltaAbs).toBe(0); + expect(transferRow.cumulativeCurrentAmount).toBe(0); + // Ordinary expense keeps EXACTLY its sum of outflows. + expect(expenseRow.currentAmount).toBe(200); + expect(expenseRow.previousAmount).toBe(150); + }); +}); + describe("getCategoryZoom", () => { it("uses a bounded recursive CTE when including subcategories", async () => { mockSelect diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 2b72eb6..dcdb859 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -434,6 +434,47 @@ function previousMonth(year: number, month: number): { year: number; month: numb return { year, month: month - 1 }; } +/** + * Shared aggregation for the real-vs-real compare (both MoM and YoY drive the + * exact same query — only the eight date bounds differ). Four date buckets + * ($1..$8): monthly current/previous and cumulative current/previous. + * + * Per-category sign convention (Issue #243): + * - `transfer` categories NET via a signed SUM(t.amount), so a balanced + * debit/credit pair (e.g. "Paiement CC": -500 out, +500 in) cancels to ~0. + * A transfer is a money move, not spending, and must not inflate the + * expense figures. + * - Every other type keeps the expense-report behavior: only outflows + * (amount < 0) count, summed as positive magnitudes via ABS. + * + * The WHERE is broadened with `OR COALESCE(c.type, 'expense') = 'transfer'` so + * transfer *credits* survive the `amount < 0` expense filter and can actually + * cancel their debits — without it the credit leg would be dropped and the + * category could never net to zero. For non-transfer rows the WHERE still + * admits only outflows, so their totals are byte-identical to the previous + * behavior (no revenues surfaced, no zero-rows for pure-income categories). + * Uncategorized rows (LEFT JOIN → c.type NULL) default to 'expense'. + */ +const COMPARE_DELTA_SQL = `SELECT + t.category_id, + COALESCE(c.name, 'Uncategorized') AS category_name, + COALESCE(c.color, '#9ca3af') AS category_color, + COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_current_total, + COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS month_previous_total, + COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_current_total, + COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN CASE WHEN COALESCE(c.type, 'expense') = 'transfer' THEN t.amount ELSE ABS(t.amount) END ELSE 0 END), 0) AS cumulative_previous_total + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE (t.amount < 0 OR COALESCE(c.type, 'expense') = 'transfer') + AND ( + (t.date >= $1 AND t.date <= $2) + OR (t.date >= $3 AND t.date <= $4) + OR (t.date >= $5 AND t.date <= $6) + OR (t.date >= $7 AND t.date <= $8) + ) + GROUP BY t.category_id, category_name, category_color + ORDER BY ABS(month_current_total - month_previous_total) DESC`; + /** * Month-over-month expense delta by category. Returns both a monthly view * (reference month vs immediately-previous month) and a cumulative YTD view @@ -461,33 +502,12 @@ export async function getCompareMonthOverMonth( const cumPreviousStart = `${prev.year}-01-01`; const cumPreviousEnd = prevEnd; - const rows = await db.select( - `SELECT - t.category_id, - COALESCE(c.name, 'Uncategorized') AS category_name, - COALESCE(c.color, '#9ca3af') AS category_color, - COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total, - COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total, - COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total, - COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_previous_total - FROM transactions t - LEFT JOIN categories c ON t.category_id = c.id - WHERE t.amount < 0 - AND ( - (t.date >= $1 AND t.date <= $2) - OR (t.date >= $3 AND t.date <= $4) - OR (t.date >= $5 AND t.date <= $6) - OR (t.date >= $7 AND t.date <= $8) - ) - GROUP BY t.category_id, category_name, category_color - ORDER BY ABS(month_current_total - month_previous_total) DESC`, - [ - curStart, curEnd, - prevStart, prevEnd, - cumCurrentStart, cumCurrentEnd, - cumPreviousStart, cumPreviousEnd, - ], - ); + const rows = await db.select(COMPARE_DELTA_SQL, [ + curStart, curEnd, + prevStart, prevEnd, + cumCurrentStart, cumCurrentEnd, + cumPreviousStart, cumPreviousEnd, + ]); return rowsToDeltas(rows); } @@ -512,33 +532,12 @@ export async function getCompareYearOverYear( const cumPreviousStart = `${year - 1}-01-01`; const cumPreviousEnd = prevMonthEnd; - const rows = await db.select( - `SELECT - t.category_id, - COALESCE(c.name, 'Uncategorized') AS category_name, - COALESCE(c.color, '#9ca3af') AS category_color, - COALESCE(SUM(CASE WHEN t.date >= $1 AND t.date <= $2 THEN ABS(t.amount) ELSE 0 END), 0) AS month_current_total, - COALESCE(SUM(CASE WHEN t.date >= $3 AND t.date <= $4 THEN ABS(t.amount) ELSE 0 END), 0) AS month_previous_total, - COALESCE(SUM(CASE WHEN t.date >= $5 AND t.date <= $6 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_current_total, - COALESCE(SUM(CASE WHEN t.date >= $7 AND t.date <= $8 THEN ABS(t.amount) ELSE 0 END), 0) AS cumulative_previous_total - FROM transactions t - LEFT JOIN categories c ON t.category_id = c.id - WHERE t.amount < 0 - AND ( - (t.date >= $1 AND t.date <= $2) - OR (t.date >= $3 AND t.date <= $4) - OR (t.date >= $5 AND t.date <= $6) - OR (t.date >= $7 AND t.date <= $8) - ) - GROUP BY t.category_id, category_name, category_color - ORDER BY ABS(month_current_total - month_previous_total) DESC`, - [ - curMonthStart, curMonthEnd, - prevMonthStart, prevMonthEnd, - cumCurrentStart, cumCurrentEnd, - cumPreviousStart, cumPreviousEnd, - ], - ); + const rows = await db.select(COMPARE_DELTA_SQL, [ + curMonthStart, curMonthEnd, + prevMonthStart, prevMonthEnd, + cumCurrentStart, cumCurrentEnd, + cumPreviousStart, cumPreviousEnd, + ]); return rowsToDeltas(rows); }