fix(reports/cartes): Budget Adherence card filtered out all expense categories #113

Merged
maximus merged 1 commit from issue-112-budget-adherence-signed-fix into main 2026-04-19 13:39:12 +00:00
4 changed files with 94 additions and 7 deletions

View file

@ -14,6 +14,7 @@
### Corrigé ### Corrigé
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101) - **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)
- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101) - **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101)
- **Rapport Cartes — adhésion budgétaire** : la carte affichait systématiquement « aucune catégorie avec budget ce mois-ci » même lorsque des budgets étaient définis sur les catégories de dépenses. Cause racine : les budgets de dépenses sont stockés signés négatifs et le filtre/la comparaison utilisaient les valeurs brutes au lieu des absolus. Le nombre de catégories, les catégories dans la cible et les montants de dépassement sont maintenant tous calculés sur les valeurs absolues (#112)
## [0.8.2] - 2026-04-17 ## [0.8.2] - 2026-04-17

View file

@ -14,6 +14,7 @@
### Fixed ### Fixed
- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101) - **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101)
- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101) - **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101)
- **Cartes report — budget adherence**: the card was always saying "no budgeted categories this month" even when budgets were defined on expense categories. Root cause: expense budgets are stored signed-negative, and the filter/comparison used raw values instead of absolutes. Categories, in-target counts, and worst-overrun amounts are now all computed on absolute values (#112)
## [0.8.2] - 2026-04-17 ## [0.8.2] - 2026-04-17

View file

@ -230,4 +230,85 @@ describe("getCartesSnapshot", () => {
// Top down is the biggest negative delta (D3: -800) // Top down is the biggest negative delta (D3: -800)
expect(snapshot.topMoversDown[0].categoryName).toBe("D3"); expect(snapshot.topMoversDown[0].categoryName).toBe("D3");
}); });
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);
});
}); });

View file

@ -907,25 +907,29 @@ export async function getCartesSnapshot(
.map(toCartesMover); .map(toCartesMover);
// Budget adherence — only expense categories with a non-zero budget count. // Budget adherence — only expense categories with a non-zero budget count.
// monthActual is signed from transactions; expense categories have // Both `monthActual` and `monthBudget` are signed (expense categories are
// monthActual <= 0, so we compare on absolute values. // stored with a negative sign by `budgetService`), so all comparisons and
// deltas on the adherence card must go through `Math.abs` — otherwise the
// `> 0` filter rejects every expense row and the card shows "no budgeted
// categories this month" even when budgets exist.
const budgetedExpenseRows = budgetRows.filter( const budgetedExpenseRows = budgetRows.filter(
(r) => r.category_type === "expense" && r.monthBudget > 0 && !r.is_parent, (r) => r.category_type === "expense" && Math.abs(r.monthBudget) > 0 && !r.is_parent,
); );
const budgetsInTarget = budgetedExpenseRows.filter( const budgetsInTarget = budgetedExpenseRows.filter(
(r) => Math.abs(r.monthActual) <= r.monthBudget, (r) => Math.abs(r.monthActual) <= Math.abs(r.monthBudget),
).length; ).length;
const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows const overruns: CartesBudgetWorstOverrun[] = budgetedExpenseRows
.map((r) => { .map((r) => {
const actual = Math.abs(r.monthActual); const actual = Math.abs(r.monthActual);
const overrunAbs = actual - r.monthBudget; const budget = Math.abs(r.monthBudget);
const overrunPct = r.monthBudget > 0 ? (overrunAbs / r.monthBudget) * 100 : null; const overrunAbs = actual - budget;
const overrunPct = budget > 0 ? (overrunAbs / budget) * 100 : null;
return { return {
categoryId: r.category_id, categoryId: r.category_id,
categoryName: r.category_name, categoryName: r.category_name,
categoryColor: r.category_color, categoryColor: r.category_color,
budget: r.monthBudget, budget,
actual, actual,
overrunAbs, overrunAbs,
overrunPct, overrunPct,