Merge PR #248: fix(reports) net transfer-type categories in real-vs-real compare (#243)

This commit is contained in:
le king fu 2026-07-05 16:59:17 -04:00
commit a6fa89e041
4 changed files with 172 additions and 54 deletions

View file

@ -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é

View file

@ -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

View file

@ -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

View file

@ -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<RawDeltaRow[]>(
`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<RawDeltaRow[]>(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<RawDeltaRow[]>(
`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<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
curMonthStart, curMonthEnd,
prevMonthStart, prevMonthEnd,
cumCurrentStart, cumCurrentEnd,
cumPreviousStart, cumPreviousEnd,
]);
return rowsToDeltas(rows);
}