Add budget block review with monthly comparison

This commit is contained in:
JKuijperM 2026-10-21 19:14:16 +02:00
parent 41831990f0
commit 7c6777091d
5 changed files with 519 additions and 12 deletions

View File

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

View File

@ -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 '<label class="sr-only" for="reviewYear">' in content
assert '<label class="sr-only" for="reviewMonth">' in content
def test_definition_and_review_link_to_each_other(auth_client):
review = auth_client.get(reverse("budget_review")).content.decode()
definition = auth_client.get(reverse("budget_blocks")).content.decode()
assert 'href="%s"' % reverse("budget_blocks") in review
assert 'href="%s"' % reverse("budget_review") in definition
# --------------------------------------------------------------------------
# Resumen del dashboard
# --------------------------------------------------------------------------
def _current_month_overrun(user):
"""Mes en curso: Fijo esencial al 50 % (500 €) y se gastan 700 €."""
today = date.today()
checking = _account(user, "Nómina")
_income(user, checking, "1000", on=today.replace(day=1))
_define(user, fixed_essential="50", variable_essential="30")
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "700", on=today.replace(day=1))
_expense(user, checking, _category(user, "Compra", "variable_essential"), "100", on=today.replace(day=1))
return checking
def test_dashboard_summary_lists_only_exceeded_blocks(auth_client, user):
_current_month_overrun(user)
response = auth_client.get(reverse("dashboard"))
overruns = response.context["budget_overruns"]
assert [row["block"] for row in overruns] == ["fixed_essential"]
content = response.content.decode()
assert "mes en curso" in content
assert 'href="%s"' % reverse("budget_review") in content
def test_dashboard_summary_says_when_a_block_had_nothing_assigned(auth_client, user):
checking = _current_month_overrun(user)
_expense(user, checking, _category(user, "Netflix", "fixed_discretionary"), "13", on=date.today().replace(day=1))
content = auth_client.get(reverse("dashboard")).content.decode()
assert "13,00 € sin nada asignado" in content
assert "de 0,00 €" not in content
def test_dashboard_summary_hidden_when_nothing_is_exceeded(auth_client, user):
today = date.today()
checking = _account(user, "Nómina")
_income(user, checking, "1000", on=today.replace(day=1))
_define(user, fixed_essential="50")
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "400", on=today.replace(day=1))
response = auth_client.get(reverse("dashboard"))
assert response.context["budget_overruns"] == []
assert "mes en curso" not in response.content.decode()
def test_dashboard_summary_hidden_without_a_defined_budget(auth_client, user):
# Sin reparto definido, cada bloque con gasto sería un "exceso total": el
# resumen avisaría de todo a quien nunca ha usado el reparto.
today = date.today()
checking = _account(user, "Nómina")
_income(user, checking, "1000", on=today.replace(day=1))
_expense(user, checking, _category(user, "Alquiler", "fixed_essential"), "400", on=today.replace(day=1))
assert auth_client.get(reverse("dashboard")).context["budget_overruns"] == []
def _dashboard_queries(client):
with CaptureQueriesContext(connection) as ctx:
client.get(reverse("dashboard"))
return len(ctx.captured_queries)
def test_dashboard_query_cost_of_the_summary(auth_client, user):
checking = _current_month_overrun(user)
with_summary = _dashboard_queries(auth_client)
BudgetBlock.objects.filter(owner=user).delete()
without_budget = _dashboard_queries(auth_client)
# Sin reparto definido el resumen se corta tras leer los bloques. Con él,
# suma ingresos del mes, gasto por categoría (2) y traspasos: 4 más.
assert with_summary - without_budget == 4
_define(user, fixed_essential="50")
for i in range(8):
_expense(user, checking, _category(user, f"Extra {i}", "fixed_essential"), "1", on=date.today().replace(day=1))
assert _dashboard_queries(auth_client) == with_summary
# --------------------------------------------------------------------------
# Aislamiento y login
# --------------------------------------------------------------------------
def test_review_only_sees_own_data(auth_client, user, django_user_model):
other = django_user_model.objects.create_user(username="otro", password="x-pass-123")
_known_month(other)
checking = _account(user, "Mía")
_income(user, checking, "1000")
review = auth_client.get(reverse("budget_review"), MARCH).context["review"]
assert review["income"] == Decimal("1000")
assert all(row["defined_pct"] == 0 and row["real_amount"] == 0 for row in review["rows"])
def test_review_requires_login(client):
response = client.get(reverse("budget_review"))
assert response.status_code == 302
assert reverse("login") in response["Location"]

View File

@ -122,6 +122,8 @@ DASHBOARD_CONTEXT_KEYS = {
# Objetivos y listado reciente
"goals",
"recent_expenses",
# Bloques del reparto que se pasan en el mes en curso
"budget_overruns",
# Medias y proyección
"daily_average",
"projected_end_of_month",

View File

@ -34,6 +34,7 @@ urlpatterns = [
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),
path('settings/', views.settings_index, name='settings_index'),
path('budget/', views.budget_blocks, name='budget_blocks'),
path('budget/review/', views.budget_review, name='budget_review'),
path('export/', views.export_index, name='export_index'),
path('export/download/', views.export_download, name='export_download'),
path('goals/', views.goal_list, name='goal_list'),

View File

@ -14,6 +14,8 @@ from .models import (
Goal,
Transfer,
BudgetBlock,
bar_width,
progress_state,
)
from .forms import (
ExpenseForm,
@ -257,6 +259,80 @@ def _budget_block_proposal(user, today):
}
def _budget_review(user, year, month, only_if_defined=False):
"""Definido contra real del reparto por bloques en un mes.
El denominador es el ingreso real del mes, sin traspasos. Devuelve None si
no hubo ingresos: sin denominador no hay reparto que comparar. Con
`only_if_defined` también devuelve None si el usuario no ha asignado nada
a ningún bloque, y se corta tras esa primera consulta: el resumen del
dashboard no debe pagar el cálculo, ni avisar de excesos, a quien no usa el
reparto.
Son cinco consultas fijas: el reparto guardado, los ingresos, dos de
_expense_type_breakdown y una de _savings_total."""
defined = {
row.block: row.percentage for row in BudgetBlock.objects.filter(owner=user)
}
if only_if_defined and not any(defined.values()):
return None
date_from = date(year, month, 1)
date_to = date(year + 1, 1, 1) if month == 12 else date(year, month + 1, 1)
income = _analysis_incomes(user).filter(
date__gte=date_from, date__lt=date_to
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
if income <= 0:
return None
expenses = _analysis_expenses(user).filter(date__gte=date_from, date__lt=date_to)
breakdown, _ = _expense_type_breakdown(user, expenses)
real = {row["expense_type"]: row["total"] for row in breakdown}
real[BudgetBlock.BLOCK_SAVING] = _savings_total(user, date_from, date_to)
rows = []
for block, label in BudgetBlock.BLOCK_CHOICES:
is_saving = block == BudgetBlock.BLOCK_SAVING
defined_pct = defined.get(block, Decimal("0"))
defined_amount = income * defined_pct / 100
real_amount = real[block]
if defined_amount:
progress = real_amount / defined_amount * 100
elif real_amount > 0:
# 0 % definido y gasto real: exceso total. El cociente no existe,
# así que la barra va llena y en la plantilla se enseña el importe.
progress = Decimal("Infinity")
else:
progress = Decimal("0")
rows.append({
"block": block,
"label": label,
"defined_pct": defined_pct,
"defined_amount": defined_amount,
"real_pct": real_amount / income * 100,
"real_amount": real_amount,
"progress": None if progress.is_infinite() else progress,
"bar_width": bar_width(progress),
# El ahorro es una meta, no un límite: superarlo es lo que se busca.
"state": progress_state(progress, is_limit=not is_saving),
"exceeded": not is_saving and real_amount > defined_amount,
})
unassigned_pct = 100 - sum(defined.values(), Decimal("0"))
return {
"income": income,
"rows": rows,
"defined_any": any(defined.values()),
# No es un bloque: sin tipo resuelto no tiene objetivo con el que
# compararse. Cuanto más pese, menos fiable es la comparación.
"unclassified": {"amount": real[""], "pct": real[""] / income * 100},
"unassigned": {"pct": unassigned_pct, "amount": income * unassigned_pct / 100},
}
def _without_transfers(movements):
"""Quita las patas de traspaso de un queryset de gastos o de ingresos.
@ -1115,6 +1191,18 @@ def dashboard(request):
.order_by('-date', '-id')[:7]
)
# Siempre sobre el mes en curso y todas las cuentas, sin mirar los filtros
# de arriba: la plantilla lo dice en el propio texto para que no parezca
# que contradice al resto de la pantalla.
budget_month = today.replace(day=1)
current_review = _budget_review(
request.user, today.year, today.month, only_if_defined=True
)
budget_overruns = (
[row for row in current_review["rows"] if row["exceeded"]]
if current_review else []
)
# Send the data to the dashboard
return render(
request,
@ -1158,6 +1246,8 @@ def dashboard(request):
"period": period,
"goals": goals,
"recent_expenses": recent_expenses,
"budget_overruns": budget_overruns,
"budget_month": budget_month,
"daily_average": daily_average,
"projected_end_of_month": projected_end_of_month,
},
@ -1854,6 +1944,39 @@ def settings_index(request):
return render(request, "settings/index.html", {"active_menu": "settings"})
@login_required
def budget_review(request):
today = date.today()
year = _get_int(request.GET.get("year"))
month = _get_int(request.GET.get("month"))
# Un mes inválido o a medias se ignora y se cae al mes en curso, igual
# que _get_int con un número mal escrito: nada de 400.
if not (year and 1 <= year <= 9999 and month and 1 <= month <= 12):
year, month = today.year, today.month
years = {today.year} | {
d.year
for qs in (Expense.objects.filter(owner=request.user), Income.objects.filter(owner=request.user))
for d in qs.dates("date", "year")
}
return render(
request,
"settings/budget_review.html",
{
"active_menu": "settings",
"review": _budget_review(request.user, year, month),
"selected_year": year,
"selected_month": month,
"selected_date": date(year, month, 1),
"year_list": sorted(years | {year}, reverse=True),
# Fechas y no números para que la plantilla saque el nombre del
# mes localizado con |date:"F".
"months": [date(2000, m, 1) for m in range(1, 13)],
},
)
@login_required
def budget_blocks(request):
proposal = _budget_block_proposal(request.user, date.today())