fix(reports/cartes): Budget Adherence card was filtering out all expense categories
Expense budgets are stored signed-negative by budgetService. The Cartes budget-adherence card used raw values in its filter (monthBudget > 0), its in-target comparison (|actual| <= monthBudget), and its overrun calculation — all of which silently rejected every expense row. Route every amount through Math.abs() so the card reflects real budget data. Test: regression fixture with a signed-negative monthBudget that must pass the filter and count as in-target or overrun based on absolute values. Fixes #112
This commit is contained in:
parent
da6041dc45
commit
2ec48d4e90
4 changed files with 94 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue