This commit is contained in:
commit
a6fa89e041
4 changed files with 172 additions and 54 deletions
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
[
|
||||
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`,
|
||||
[
|
||||
const rows = await db.select<RawDeltaRow[]>(COMPARE_DELTA_SQL, [
|
||||
curMonthStart, curMonthEnd,
|
||||
prevMonthStart, prevMonthEnd,
|
||||
cumCurrentStart, cumCurrentEnd,
|
||||
cumPreviousStart, cumPreviousEnd,
|
||||
],
|
||||
);
|
||||
]);
|
||||
return rowsToDeltas(rows);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue