From 7c6777091d6536cec328affaec27e357a2fbf247 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 21 Oct 2026 19:14:16 +0200 Subject: [PATCH] Add budget block review with monthly comparison --- expenses_manager/expenses/models.py | 40 +- .../expenses/tests/test_budget_review.py | 365 ++++++++++++++++++ expenses_manager/expenses/tests/test_smoke.py | 2 + expenses_manager/expenses/urls.py | 1 + expenses_manager/expenses/views.py | 123 ++++++ 5 files changed, 519 insertions(+), 12 deletions(-) create mode 100644 expenses_manager/expenses/tests/test_budget_review.py diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 0265787..e00d0d8 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -600,6 +600,31 @@ class FuelEntry(models.Model): return None +def bar_width(pct): + """Ancho de una barra de progreso: el porcentaje, capado entre 0 y 100 para + que no desborde al pasarse ni quede negativa con un ahorro neto negativo.""" + return max(min(float(pct), 100), 0) + + +def progress_state(pct, is_limit): + """Clase de color de una barra de progreso según el porcentaje alcanzado. + + `is_limit` decide el criterio. En un límite (un presupuesto, un bloque de + gasto) pasarse es malo y acercarse ya avisa. En una meta (un ahorro) es al + revés: llegar es el objetivo y pasarse no tiene nada de malo. Lo comparten + Goal y la revisión del reparto por bloques, para que el mismo criterio no + viva en dos sitios. + """ + if is_limit: + if pct > 100: + return "danger" + if pct >= 80: + return "warning" + return "ok" + + return "complete" if pct >= 100 else "ok" + + def _deduped_total(movements): """Suma importes sin que el JOIN del M2M cuente la misma fila dos veces. @@ -809,19 +834,10 @@ class Goal(models.Model): return (self.progress / self.target_amount) * 100 def bar_width(self): - return min(float(self.percentage()), 100) - + return bar_width(self.percentage()) + def progress_state(self): - pct = self.percentage() - - if self.kind == self.KIND_BUDGET: - if pct > 100: - return "danger" - if pct >= 80: - return "warning" - return "ok" - - return "complete" if pct >= 100 else "ok" + return progress_state(self.percentage(), is_limit=self.kind == self.KIND_BUDGET) def is_exceeded(self): """True si es un presupuesto que ya se ha pasado del límite.""" diff --git a/expenses_manager/expenses/tests/test_budget_review.py b/expenses_manager/expenses/tests/test_budget_review.py new file mode 100644 index 0000000..b4ce30e --- /dev/null +++ b/expenses_manager/expenses/tests/test_budget_review.py @@ -0,0 +1,365 @@ +import pytest +from datetime import date +from decimal import Decimal + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from expenses.models import Account, BudgetBlock, Category, Expense, Income, Transfer + +pytestmark = pytest.mark.django_db + +MARCH = {"year": 2024, "month": 3} + + +def _account(user, name, is_savings=False): + return Account.objects.create( + owner=user, name=name, initial_balance=Decimal("0"), active=True, + is_savings=is_savings, + ) + + +def _category(user, name, expense_type=""): + return Category.objects.create(name=name, owner=user, expense_type=expense_type) + + +def _expense(user, account, category, amount, on=date(2024, 3, 10)): + return Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal(amount), date=on, + ) + + +def _income(user, account, amount, on=date(2024, 3, 1)): + return Income.objects.create( + owner=user, account=account, name="Nómina", amount=Decimal(amount), date=on, + ) + + +def _transfer(user, from_account, to_account, amount, on=date(2024, 3, 20)): + transfer = Transfer( + owner=user, date=on, amount=Decimal(amount), + from_account=from_account, to_account=to_account, + ) + transfer.sync() + return transfer + + +def _define(user, **percentages): + for block, value in percentages.items(): + BudgetBlock.objects.create(owner=user, block=block, percentage=Decimal(value)) + + +def _rows(response): + return {row["block"]: row for row in response.context["review"]["rows"]} + + +def _known_month(user): + """Marzo de 2024: 2000 € de ingreso y un gasto conocido en cada bloque.""" + checking = _account(user, "Nómina") + savings = _account(user, "Hucha", is_savings=True) + _income(user, checking, "2000") + _define( + user, + fixed_essential="40", # 800 € + fixed_discretionary="5", # 100 € + variable_essential="20", # 400 € + variable_discretionary="10", # 200 € + saving="15", # 300 €; entre todos, 90 % y quedan 10 % sin asignar + ) + _expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "900") + _expense(user, checking, _category(user, "Netflix", "fixed_discretionary"), "50") + _expense(user, checking, _category(user, "Compra", "variable_essential"), "340") + _expense(user, checking, _category(user, "Varios"), "100") # sin tipo + _transfer(user, checking, savings, "400") + # Fuera de marzo: no cuenta. + _expense(user, checking, _category(user, "Abril", "fixed_essential"), "999", on=date(2024, 4, 1)) + return checking, savings + + +# -------------------------------------------------------------------------- +# Definido contra real +# -------------------------------------------------------------------------- + +def test_each_block_has_defined_and_real_percentage_and_amount(auth_client, user): + _known_month(user) + + response = auth_client.get(reverse("budget_review"), MARCH) + + rows = _rows(response) + assert list(rows) == [block for block, _ in BudgetBlock.BLOCK_CHOICES] + expected = { + # bloque: (definido %, definido €, real %, real €) + "fixed_essential": ("40", "800", "45", "900"), + "fixed_discretionary": ("5", "100", "2.5", "50"), + "variable_essential": ("20", "400", "17", "340"), + "variable_discretionary": ("10", "200", "0", "0"), + "saving": ("15", "300", "20", "400"), + } + for block, (defined_pct, defined_amount, real_pct, real_amount) in expected.items(): + row = rows[block] + assert row["defined_pct"] == Decimal(defined_pct), block + assert row["defined_amount"] == Decimal(defined_amount), block + assert row["real_pct"] == Decimal(real_pct), block + assert row["real_amount"] == Decimal(real_amount), block + assert response.context["review"]["income"] == Decimal("2000") + + +def test_expense_blocks_follow_the_budget_criterion(auth_client, user): + _known_month(user) + + rows = _rows(auth_client.get(reverse("budget_review"), MARCH)) + + assert (rows["fixed_essential"]["state"], rows["fixed_essential"]["exceeded"]) == ("danger", True) # 112,5 % + assert (rows["variable_essential"]["state"], rows["variable_essential"]["exceeded"]) == ("warning", False) # 85 % + assert (rows["fixed_discretionary"]["state"], rows["fixed_discretionary"]["exceeded"]) == ("ok", False) # 50 % + assert rows["fixed_essential"]["bar_width"] == 100 + + +def test_saving_above_defined_is_good(auth_client, user): + _known_month(user) # 400 € ahorrados sobre 300 € previstos + + saving = _rows(auth_client.get(reverse("budget_review"), MARCH))["saving"] + + assert saving["state"] == "complete" + assert saving["exceeded"] is False + assert saving["bar_width"] == 100 + + +def test_saving_below_defined_is_not_complete(auth_client, user): + checking = _account(user, "Nómina") + _income(user, checking, "2000") + _define(user, saving="15") + _transfer(user, checking, _account(user, "Hucha", is_savings=True), "150") + + saving = _rows(auth_client.get(reverse("budget_review"), MARCH))["saving"] + + assert saving["state"] == "ok" # 50 % de lo previsto, sin llegar + assert saving["exceeded"] is False + assert saving["bar_width"] == 50 + + +def test_block_with_zero_defined_and_real_spending_is_a_total_excess(auth_client, user): + checking = _account(user, "Nómina") + _income(user, checking, "1000") + _define(user, fixed_essential="50") + _expense(user, checking, _category(user, "Caprichos", "variable_discretionary"), "80") + + response = auth_client.get(reverse("budget_review"), MARCH) + + row = _rows(response)["variable_discretionary"] + assert row["defined_pct"] == Decimal("0") + assert row["real_amount"] == Decimal("80") + assert row["progress"] is None + assert row["bar_width"] == 100 + assert row["state"] == "danger" + assert row["exceeded"] is True + assert 'aria-valuenow="100"' in response.content.decode() + + +def test_progressbar_aria_uses_capped_width_without_locale_comma(auth_client, user): + _known_month(user) + + content = auth_client.get(reverse("budget_review"), MARCH).content.decode() + + # Fijo prescindible: 50 € de 100 € → 50 %. Fijo esencial pasa del 100 % y + # se capa: aria-valuenow nunca es 112,5. + assert 'aria-valuenow="50' in content + assert 'aria-valuenow="112' not in content + assert 'aria-valuenow="100"' in content + assert "112,5 %" in content # el real sí se dice, con coma, en el texto + + +def test_unclassified_is_shown_apart_and_is_not_a_block(auth_client, user): + _known_month(user) + + response = auth_client.get(reverse("budget_review"), MARCH) + + review = response.context["review"] + assert "" not in _rows(response) + assert review["unclassified"] == {"amount": Decimal("100"), "pct": Decimal("5")} + assert "poco fiable" in response.content.decode() + + +def test_unassigned_part_of_the_income(auth_client, user): + _known_month(user) + + review = auth_client.get(reverse("budget_review"), MARCH).context["review"] + + assert review["unassigned"] == {"pct": Decimal("10"), "amount": Decimal("200")} + + +def test_monthly_saving_respects_the_three_transfer_cases(auth_client, user): + checking = _account(user, "Nómina") + piggy = _account(user, "Hucha", is_savings=True) + deposit = _account(user, "Depósito", is_savings=True) + _income(user, checking, "1000") + _transfer(user, checking, piggy, "300") # a ahorro: suma + _transfer(user, piggy, checking, "100") # de ahorro: resta + _transfer(user, piggy, deposit, "500") # entre ahorros: no cuenta + + saving = _rows(auth_client.get(reverse("budget_review"), MARCH))["saving"] + + assert saving["real_amount"] == Decimal("200") + + +def test_month_without_income_shows_empty_state(auth_client, user): + checking = _account(user, "Nómina") + _define(user, fixed_essential="50") + _expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "700") + + response = auth_client.get(reverse("budget_review"), MARCH) + + assert response.context["review"] is None + content = response.content.decode() + assert "sin ingresos registrados" in content + assert 'role="progressbar"' not in content + + +# -------------------------------------------------------------------------- +# Selector de mes +# -------------------------------------------------------------------------- + +def test_defaults_to_the_current_month(auth_client): + response = auth_client.get(reverse("budget_review")) + + today = date.today() + assert (response.context["selected_year"], response.context["selected_month"]) == (today.year, today.month) + + +@pytest.mark.parametrize("params", [ + {"year": "abc", "month": "3"}, + {"year": "2024", "month": "13"}, + {"year": "2024", "month": "0"}, + {"year": "0", "month": "3"}, + {"year": "2024"}, +]) +def test_invalid_month_in_url_falls_back_to_current_month(auth_client, params): + response = auth_client.get(reverse("budget_review"), params) + + today = date.today() + assert response.status_code == 200 + assert (response.context["selected_year"], response.context["selected_month"]) == (today.year, today.month) + + +def test_month_selector_has_labels(auth_client): + content = auth_client.get(reverse("budget_review")).content.decode() + + assert '