diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index a8394a9..3a92d17 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django import forms from .csv_export import DEFAULT_DIALECT from .models import ( @@ -9,6 +11,7 @@ from .models import ( FuelEntry, Goal, Transfer, + BudgetBlock, ) @@ -107,7 +110,7 @@ class AccountForm(forms.ModelForm): class Meta: model = Account - fields = ["name", "initial_balance", "active"] + fields = ["name", "initial_balance", "active", "is_savings"] class IncomeForm(_GoalLinksMixin, forms.ModelForm): @@ -290,6 +293,55 @@ class GoalForm(forms.ModelForm): return cleaned +class BudgetBlockForm(forms.Form): + """El reparto del ingreso por bloques: un campo por cada uno de los cinco. + + La suma no puede pasar de 100, pero no se obliga a que llegue: lo que falte + es "sin asignar", que es información útil y no un error. + """ + + def __init__(self, *args, user, **kwargs): + self.user = user + saved = { + row.block: row.percentage + for row in BudgetBlock.objects.filter(owner=user) + } + kwargs.setdefault( + "initial", + # Un bloque sin fila guardada vale 0. + {block: saved.get(block, Decimal("0")) for block, _ in BudgetBlock.BLOCK_CHOICES}, + ) + super().__init__(*args, **kwargs) + + for block, label in BudgetBlock.BLOCK_CHOICES: + self.fields[block] = forms.DecimalField( + label=f"{label} (%)", + min_value=0, + max_value=100, + max_digits=5, + decimal_places=2, + ) + + def clean(self): + cleaned = super().clean() + values = [cleaned.get(block) for block, _ in BudgetBlock.BLOCK_CHOICES] + # Si algún campo ya falló, su error basta: la suma saldría con huecos. + if None not in values and sum(values) > 100: + raise forms.ValidationError( + "Los bloques suman %s %%; entre todos no pueden pasar del 100 %%." + % sum(values) + ) + return cleaned + + def save(self): + for block, _ in BudgetBlock.BLOCK_CHOICES: + BudgetBlock.objects.update_or_create( + owner=self.user, + block=block, + defaults={"percentage": self.cleaned_data[block]}, + ) + + class ExportForm(forms.Form): """Filtros de la exportación a CSV. Valida la pantalla y la descarga. diff --git a/expenses_manager/expenses/migrations/0019_account_is_savings_budgetblock.py b/expenses_manager/expenses/migrations/0019_account_is_savings_budgetblock.py new file mode 100644 index 0000000..b968216 --- /dev/null +++ b/expenses_manager/expenses/migrations/0019_account_is_savings_budgetblock.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.10 on 2026-10-18 14:51 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0018_category_expense_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='is_savings', + field=models.BooleanField(default=False, help_text='Márcala si es una cuenta donde guardas dinero: los traspasos que entran en ella cuentan como ahorro en el reparto por bloques.', verbose_name='Cuenta de ahorro'), + ), + migrations.CreateModel( + name='BudgetBlock', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('block', models.CharField(choices=[('fixed_essential', 'Fijo esencial'), ('fixed_discretionary', 'Fijo prescindible'), ('variable_essential', 'Variable esencial'), ('variable_discretionary', 'Variable prescindible'), ('saving', 'Ahorro')], max_length=30)), + ('percentage', models.DecimalField(decimal_places=2, default=0, max_digits=5, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_blocks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('owner', 'block')}, + }, + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index eb0ca44..0265787 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -1,6 +1,7 @@ from datetime import date from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction from django.conf import settings from django.db.models import Q, Sum @@ -114,6 +115,14 @@ class Account(models.Model): name = models.CharField(max_length=100) initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0) active = models.BooleanField(default=True) + is_savings = models.BooleanField( + default=False, + verbose_name="Cuenta de ahorro", + help_text=( + "Márcala si es una cuenta donde guardas dinero: los traspasos que " + "entran en ella cuentan como ahorro en el reparto por bloques." + ), + ) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -823,3 +832,35 @@ class Goal(models.Model): def __str__(self): return self.name + + +class BudgetBlock(models.Model): + """Qué porcentaje del ingreso debería ir a un bloque. + + Los bloques son los cuatro tipos de gasto de Category más el ahorro: lo que + el usuario clasificó en sus categorías es lo que ve aquí, sin un tercer eje. + + Un único reparto por usuario, sin versionar por fecha: un histórico obliga + a decidir qué reparto aplica a cada mes, y eso se añade el día que haga + falta. Un bloque sin fila vale 0. + """ + + BLOCK_SAVING = "saving" + BLOCK_CHOICES = Category.EXPENSE_TYPE_CHOICES + [(BLOCK_SAVING, "Ahorro")] + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="budget_blocks" + ) + block = models.CharField(max_length=30, choices=BLOCK_CHOICES) + percentage = models.DecimalField( + max_digits=5, + decimal_places=2, + default=0, + validators=[MinValueValidator(0), MaxValueValidator(100)], + ) + + class Meta: + unique_together = ("owner", "block") + + def __str__(self): + return f"{self.get_block_display()}: {self.percentage} %" diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 8be272a..3be877b 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -561,36 +561,85 @@ button[type="submit"]:hover, } /* Cabecera común de las tarjetas de gráfico: el título a la izquierda y, si lo - lleva, su control a la derecha. El min-height reserva el alto del selector - también en las tarjetas que no lo tienen, así que los dos ejes arrancan a la - misma altura por construcción y no por casualidad. Por eso no se compensa - tocando el alto del canvas: eso cuadraría solo a un ancho concreto. + lleva, su control a la derecha. Cuando el control no cabe junto al título, + baja a una segunda línea. Eso pasa a anchos de escritorio normales: el de + tipo de gasto lleva dos grupos de controles y una tarjeta mide ~340 px con + cuatro por fila. El min-height reserva esas dos líneas (título de 2.25rem, + 0.5rem de separación y controles de 2.25rem) en todas las tarjetas, tengan + controles o no. Así los canvas de una misma fila arrancan a la misma altura + por construcción y no por casualidad. Por eso no se compensa tocando el alto + del canvas: eso cuadraría solo a un ancho concreto. Los márgenes son los que traía el h2, para no mover el ritmo vertical. */ .chart-header { display: flex; + flex-wrap: wrap; justify-content: space-between; align-items: center; - gap: 1rem; - min-height: 2.5rem; + align-content: flex-start; + gap: 0.5rem 1rem; + min-height: 5rem; margin: 1.5rem 0 0.75rem; } /* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height - dejaría de mandar. */ + dejaría de mandar. El alto mínimo iguala la línea del título a la de los + controles: haya o no un control al lado, el título queda a la misma altura + en todas las tarjetas. */ .chart-header h2 { margin: 0; + min-height: 2.25rem; + display: flex; + align-items: center; } -/* El alto del control se fija por debajo del min-height de la cabecera. Así la - cabecera mide siempre el min-height en las dos tarjetas y la alineación no - depende de cuánto mida un en cada navegador, que es justo lo que haría que + cuadrara en uno y no en otro. */ .chart-header select { height: 2.25rem; padding-top: 0; padding-bottom: 0; } +/* Grupo de botones de icono que se excluyen entre sí (la forma del gráfico). + Mismo alto que el + + + + +
+ + + +
+ + {% endif %} + + {% if expense_type_classified_count < 2 %} +
+

Clasifica al menos dos categorías para ver este desglose

+ Ir a categorías +
+ {% elif kpi_total %} +
+ +
+ {% else %} +

No hay gastos en este periodo

+ {% endif %} + + + + +
+

Gastos Recientes

@@ -267,9 +320,8 @@
-
- - + +

Evolución anual por cuenta ({{ selected_year }})

diff --git a/expenses_manager/expenses/templates/settings/budget_blocks.html b/expenses_manager/expenses/templates/settings/budget_blocks.html new file mode 100644 index 0000000..d99a2f9 --- /dev/null +++ b/expenses_manager/expenses/templates/settings/budget_blocks.html @@ -0,0 +1,76 @@ +{% extends "expenses/base.html" %} + +{% block title %}Reparto por bloques{% endblock %} + +{% block content %} +

Reparto por bloques

+ +

Qué porcentaje de tus ingresos debería ir a cada bloque. Los bloques de gasto son los tipos que asignas a tus categorías; el ahorro son los traspasos a tus cuentas de ahorro.

+ + {# Arranca con el reparto guardado; el script de abajo lo recalcula al teclear. #} +

Asignado: {{ assigned|floatformat:"-2" }} %. Sin asignar: {{ unassigned|floatformat:"-2" }} %.

+ + {% if proposal %} +

+ Junto a cada bloque tienes lo que fue de verdad del {{ proposal.date_from|date:"d/m/Y" }} al {{ proposal.date_to|date:"d/m/Y" }}, sobre tus ingresos de ese periodo. + {% if proposal.unclassified %}El {{ proposal.unclassified }} % se fue en gastos sin tipo, que no cuentan en ningún bloque.{% endif %} +

+ {% else %} +

No hay ingresos en los últimos 12 meses completos, así que no se puede calcular una propuesta.

+ {% endif %} + +
+ {% csrf_token %} + {% include "expenses/_form_fields.html" %} + +
+ + {% if proposal %} + Usar valores calculados + {% endif %} + Volver +
+ {% if proposal_total > 100 %} +

Los valores calculados suman un {{ proposal_total }} %: en ese periodo gastaste más de lo que ingresaste. Puedes cargarlos, pero tendrás que bajar algún bloque hasta que la suma no pase de 100 para poder guardar.

+ {% endif %} +
+ + +{% endblock %} diff --git a/expenses_manager/expenses/templates/settings/index.html b/expenses_manager/expenses/templates/settings/index.html index 29eaf4e..1202c67 100644 --- a/expenses_manager/expenses/templates/settings/index.html +++ b/expenses_manager/expenses/templates/settings/index.html @@ -25,6 +25,11 @@

Define y controla tus metas de gasto.

+ +

Reparto por bloques

+

Decide qué parte de tus ingresos va a cada tipo de gasto y al ahorro.

+
+

Exportar datos

Descarga tus gastos, ingresos o traspasos en CSV.

diff --git a/expenses_manager/expenses/tests/test_budget_blocks.py b/expenses_manager/expenses/tests/test_budget_blocks.py new file mode 100644 index 0000000..04616b0 --- /dev/null +++ b/expenses_manager/expenses/tests/test_budget_blocks.py @@ -0,0 +1,317 @@ +import pytest +from datetime import date, timedelta +from decimal import Decimal + +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from expenses.forms import BudgetBlockForm +from expenses.models import Account, BudgetBlock, Category, Expense, Income, Transfer +from expenses.views import _budget_block_proposal, _savings_total + +pytestmark = pytest.mark.django_db + +# Con este "hoy" la propuesta mira del 01/07/2023 al 30/06/2024. +TODAY = date(2024, 7, 15) +WINDOW = (date(2023, 7, 1), date(2024, 7, 1)) + + +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 _transfer(user, from_account, to_account, amount, on=date(2024, 3, 10)): + transfer = Transfer( + owner=user, date=on, amount=Decimal(amount), + from_account=from_account, to_account=to_account, + ) + transfer.sync() + return transfer + + +def _expense(user, account, category, amount, on=date(2024, 3, 1)): + return Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal(amount), date=on, + ) + + +def _block_data(**values): + data = {block: "0" for block, _ in BudgetBlock.BLOCK_CHOICES} + data.update(values) + return data + + +# -------------------------------------------------------------------------- +# Ahorro: los tres casos de traspaso +# -------------------------------------------------------------------------- + +def test_transfer_into_a_savings_account_counts_as_saving(user): + _transfer(user, _account(user, "Nómina"), _account(user, "Hucha", is_savings=True), "300") + + assert _savings_total(user, *WINDOW) == Decimal("300") + + +def test_transfer_out_of_a_savings_account_subtracts(user): + checking = _account(user, "Nómina") + savings = _account(user, "Hucha", is_savings=True) + _transfer(user, checking, savings, "300") + _transfer(user, savings, checking, "120") + + # Sacar dinero del colchón es desahorro, no ahorro. + assert _savings_total(user, *WINDOW) == Decimal("180") + + +def test_transfer_between_two_savings_accounts_does_not_count(user): + piggy = _account(user, "Hucha", is_savings=True) + deposit = _account(user, "Depósito", is_savings=True) + _transfer(user, piggy, deposit, "1000") + _transfer(user, deposit, piggy, "250") + + # Mover dinero de una hucha a otra no es ahorro nuevo, en ningún sentido. + assert _savings_total(user, *WINDOW) == Decimal("0") + + +# -------------------------------------------------------------------------- +# Validación de la suma +# -------------------------------------------------------------------------- + +def test_form_accepts_exactly_100(user): + form = BudgetBlockForm(_block_data(fixed_essential="50", saving="50"), user=user) + + assert form.is_valid(), form.errors + + +def test_form_rejects_more_than_100(user): + form = BudgetBlockForm(_block_data(fixed_essential="51", saving="50"), user=user) + + assert not form.is_valid() + assert "no pueden pasar del 100" in str(form.non_field_errors()) + + +def test_saving_80_leaves_20_unassigned(auth_client, user): + response = auth_client.post( + reverse("budget_blocks"), + _block_data(fixed_essential="50", variable_essential="20", saving="10"), + follow=True, + ) + + assert BudgetBlock.objects.filter(owner=user).count() == 5 + assert response.context["assigned"] == Decimal("80") + assert response.context["unassigned"] == Decimal("20") + assert "Sin asignar: 20 %" in response.content.decode() + + +def test_blocks_without_rows_are_zero_and_all_five_are_shown(auth_client, user): + BudgetBlock.objects.create(owner=user, block="saving", percentage=Decimal("15")) + + response = auth_client.get(reverse("budget_blocks")) + + form = response.context["form"] + assert list(form.fields) == [block for block, _ in BudgetBlock.BLOCK_CHOICES] + assert form.initial == { + "fixed_essential": Decimal("0"), + "fixed_discretionary": Decimal("0"), + "variable_essential": Decimal("0"), + "variable_discretionary": Decimal("0"), + "saving": Decimal("15"), + } + content = response.content.decode() + for block, _ in BudgetBlock.BLOCK_CHOICES: + assert 'name="%s"' % block in content + + +# -------------------------------------------------------------------------- +# Propuesta a partir de los datos reales +# -------------------------------------------------------------------------- + +def _known_data(user): + checking = _account(user, "Nómina") + savings = _account(user, "Hucha", is_savings=True) + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal("2000"), date=date(2024, 3, 5), + ) + home = Category.objects.create( + name="Casa", owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + # Hija sin tipo propio: hereda "Fijo esencial" de Casa. + power = Category.objects.create(name="Luz", owner=user, parent=home) + fun = Category.objects.create( + name="Ocio", owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + ) + loose = Category.objects.create(name="Varios", owner=user) + _expense(user, checking, home, "500") + _expense(user, checking, power, "100") + _expense(user, checking, fun, "200") + _expense(user, checking, loose, "100") + _transfer(user, checking, savings, "300") + # Fuera de la ventana de 12 meses completos: no cuentan. + _expense(user, checking, home, "999", on=date(2024, 7, 2)) + _expense(user, checking, home, "999", on=date(2023, 6, 30)) + return checking, savings + + +def test_proposal_percentages_with_known_data(user): + _known_data(user) + + proposal = _budget_block_proposal(user, TODAY) + + assert proposal["date_from"] == date(2023, 7, 1) + assert proposal["date_to"] == date(2024, 6, 30) + assert proposal["blocks"] == { + "fixed_essential": Decimal("30.0"), # (500 + 100) / 2000 + "fixed_discretionary": Decimal("0.0"), + "variable_essential": Decimal("0.0"), + "variable_discretionary": Decimal("10.0"), # 200 / 2000 + "saving": Decimal("15.0"), # 300 / 2000 + } + assert proposal["unclassified"] == Decimal("5.0") + + +def test_proposal_uses_inherited_expense_type(user): + checking = _account(user, "Nómina") + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal("1000"), date=date(2024, 3, 5), + ) + parent = Category.objects.create( + name="Coche", owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL + ) + child = Category.objects.create(name="Gasolina", owner=user, parent=parent) + _expense(user, checking, child, "80") + + proposal = _budget_block_proposal(user, TODAY) + + assert proposal["blocks"]["variable_essential"] == Decimal("8.0") + assert proposal["unclassified"] == Decimal("0.0") + + +def test_proposal_is_none_without_income(user): + assert _budget_block_proposal(user, TODAY) is None + + +def test_proposal_query_count_does_not_grow(user): + checking, _ = _known_data(user) + + with CaptureQueriesContext(connection) as small: + _budget_block_proposal(user, TODAY) + + parent = Category.objects.get(name="Casa") + for i in range(10): + parent = Category.objects.create(name=f"Nivel {i}", owner=user, parent=parent) + _expense(user, checking, parent, "1") + + with CaptureQueriesContext(connection) as large: + _budget_block_proposal(user, TODAY) + + assert len(large.captured_queries) == len(small.captured_queries) == 4 + + +def test_use_proposal_prefills_without_saving(auth_client, user): + # La vista usa el "hoy" real: los datos van al mes pasado, que siempre cae + # dentro de los 12 meses completos. + last_month = date.today().replace(day=1) - timedelta(days=1) + checking = _account(user, "Nómina") + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal("1000"), date=last_month, + ) + _transfer(user, checking, _account(user, "Hucha", is_savings=True), "250", on=last_month) + + response = auth_client.get(reverse("budget_blocks"), {"propuesta": "1"}) + + form = response.context["form"] + assert form.initial["saving"] == Decimal("25.0") + # Con coma decimal, como el resto de números de la interfaz. + assert "Calculado: 25,0 %" in form.fields["saving"].help_text + assert not BudgetBlock.objects.filter(owner=user).exists() + + +def _last_month_income_and_expense(user, income, expense): + # La vista usa el "hoy" real: el mes pasado siempre cae dentro de los 12 + # meses completos de la propuesta. + last_month = date.today().replace(day=1) - timedelta(days=1) + checking = _account(user, "Nómina") + Income.objects.create( + owner=user, account=checking, name="Nómina", + amount=Decimal(income), date=last_month, + ) + rent = Category.objects.create( + name="Alquiler", owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + _expense(user, checking, rent, expense, on=last_month) + + +def test_proposal_over_100_is_flagged_next_to_the_button(auth_client, user): + # Más gasto que ingreso: Fijo esencial sale al 120 %. + _last_month_income_and_expense(user, income="1000", expense="1200") + + response = auth_client.get(reverse("budget_blocks")) + + assert response.context["proposal_total"] == Decimal("120.0") + assert "suman un 120,0 %" in response.content.decode() + + +def test_proposal_up_to_100_has_no_warning(auth_client, user): + _last_month_income_and_expense(user, income="1000", expense="600") + + response = auth_client.get(reverse("budget_blocks")) + + assert response.context["proposal_total"] == Decimal("60.0") + assert "suman un" not in response.content.decode() + + +# -------------------------------------------------------------------------- +# Aislamiento y login +# -------------------------------------------------------------------------- + +def test_budget_of_another_user_is_neither_shown_nor_edited(auth_client, user, django_user_model): + other = django_user_model.objects.create_user(username="otro", password="x-pass-123") + BudgetBlock.objects.create(owner=other, block="saving", percentage=Decimal("40")) + + response = auth_client.get(reverse("budget_blocks")) + assert response.context["form"].initial["saving"] == Decimal("0") + assert response.context["assigned"] == Decimal("0") + + auth_client.post(reverse("budget_blocks"), _block_data(saving="25")) + + assert BudgetBlock.objects.get(owner=other, block="saving").percentage == Decimal("40") + assert BudgetBlock.objects.get(owner=user, block="saving").percentage == Decimal("25") + + +def test_budget_screen_requires_login(client): + response = client.get(reverse("budget_blocks")) + + assert response.status_code == 302 + assert reverse("login") in response["Location"] + + +def test_budget_screen_is_linked_from_hub_and_menu(auth_client): + # Son dos sitios independientes: el hub de Configuración y el desplegable + # del menú. Con la exportación se puso solo uno y desde el menú no se + # llegaba. + hub = auth_client.get(reverse("settings_index")).content.decode() + menu = auth_client.get(reverse("home")).content.decode() + link = 'href="%s"' % reverse("budget_blocks") + + assert link in hub + assert link in menu + + +# -------------------------------------------------------------------------- +# Campo de cuenta de ahorro +# -------------------------------------------------------------------------- + +def test_account_form_saves_savings_flag_and_list_shows_it(auth_client, user): + auth_client.post( + reverse("account_create"), + {"name": "Hucha", "initial_balance": "0", "active": "on", "is_savings": "on"}, + ) + + assert Account.objects.get(owner=user, name="Hucha").is_savings is True + assert "De ahorro" in auth_client.get(reverse("account_list")).content.decode() diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py index 3e67faf..22f80d9 100644 --- a/expenses_manager/expenses/tests/test_dashboard.py +++ b/expenses_manager/expenses/tests/test_dashboard.py @@ -1,6 +1,8 @@ import pytest from datetime import date, timedelta 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, Expense, Category, Income, Transfer @@ -481,3 +483,184 @@ def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user ) assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva assert response.context['kpi_balance'] == expected + + +# -------------------------------------------------------------------------- +# Desglose por tipo de gasto +# -------------------------------------------------------------------------- + +def _breakdown(response): + return {row['label']: row['total'] for row in response.context['expense_type_breakdown']} + + +def test_expense_type_breakdown_groups_by_effective_type(auth_client, user, account): + home = Category.objects.create( + name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + power = Category.objects.create(name='Luz', owner=user, parent=home) + fun = Category.objects.create( + name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + ) + loose = Category.objects.create(name='Varios', owner=user) + for category, amount in [(home, '500'), (power, '60'), (fun, '40'), (loose, '7')]: + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal(amount), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # La luz hereda "Fijo esencial" de Casa; Varios no tiene tipo en ninguna + # parte de la cadena y va a "Sin clasificar" en vez de desaparecer. + assert _breakdown(response) == { + 'Fijo esencial': Decimal('560'), + 'Fijo prescindible': Decimal('0'), + 'Variable esencial': Decimal('0'), + 'Variable prescindible': Decimal('40'), + 'Sin clasificar': Decimal('7'), + } + assert sum(_breakdown(response).values()) == response.context['kpi_total'] + assert 'id="expenseTypeChart"' in response.content.decode() + + +def test_expense_type_breakdown_follows_the_selected_period(auth_client, user, account, category): + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal('10'), date=date(2024, 3, 1), + ) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal('99'), date=date(2024, 4, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) + + assert _breakdown(response)['Sin clasificar'] == Decimal('10') + + +def test_expense_type_breakdown_ignores_transfers(auth_client, user, account): + other = Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + _make_transfer(user, account, other, amount="400", fee="1.50") + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # Solo la comisión es gasto de verdad. + assert sum(_breakdown(response).values()) == Decimal('1.50') + + +def _classify_two_categories(user): + Category.objects.create( + name='Alquiler', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + Category.objects.create( + name='Ocio', owner=user, expense_type=Category.EXPENSE_TYPE_VARIABLE_DISCRETIONARY + ) + + +def test_expense_type_breakdown_shows_empty_state_without_expenses(auth_client, user): + _classify_two_categories(user) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert sum(_breakdown(response).values()) == 0 + content = response.content.decode() + assert 'id="expenseTypeChart"' not in content + assert 'No hay gastos en este periodo' in content + assert 'Clasifica al menos dos categorías' not in content + + +def test_expense_type_breakdown_asks_to_classify_with_fewer_than_two( + auth_client, user, account +): + # Una sola categoría clasificada; la hija hereda pero no cuenta como + # clasificada, porque el usuario no la ha clasificado él. + parent = Category.objects.create( + name='Casa', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + child = Category.objects.create(name='Luz', owner=user, parent=parent) + Expense.objects.create( + owner=user, account=account, category=child, + amount=Decimal('60'), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['expense_type_classified_count'] == 1 + content = response.content.decode() + assert 'Clasifica al menos dos categorías' in content + assert 'href="%s"' % reverse('category_list') in content + assert 'id="expenseTypeChart"' not in content + assert 'id="expenseTypeGrouping"' not in content + assert 'No hay gastos en este periodo' not in content + + +@pytest.mark.parametrize('classified, asks_to_classify', [(1, True), (2, False)]) +def test_expense_type_breakdown_classification_threshold( + auth_client, user, account, classified, asks_to_classify +): + types = [Category.EXPENSE_TYPE_FIXED_ESSENTIAL, Category.EXPENSE_TYPE_VARIABLE_ESSENTIAL] + for i in range(classified): + category = Category.objects.create(name=f'Clasificada {i}', owner=user, expense_type=types[i]) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal('10'), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['expense_type_classified_count'] == classified + content = response.content.decode() + assert ('Clasifica al menos dos categorías' in content) is asks_to_classify + assert ('id="expenseTypeChart"' in content) is not asks_to_classify + + +def test_expense_type_breakdown_renders_grouping_and_shape_controls( + auth_client, user, account +): + _classify_two_categories(user) + Expense.objects.create( + owner=user, account=account, category=Category.objects.get(name='Ocio'), + amount=Decimal('40'), date=date(2024, 3, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + content = response.content.decode() + assert 'id="expenseTypeGrouping"' in content + assert 'role="group" aria-label="Forma del gráfico"' in content + assert content.count('data-shape=') == 3 + assert content.count('aria-pressed="true"') == 1 + + +def _dashboard_queries(client): + with CaptureQueriesContext(connection) as ctx: + client.get(reverse('dashboard'), {'year': 2024}) + return len(ctx.captured_queries) + + +def test_dashboard_query_count_does_not_grow_with_categories_and_expenses( + auth_client, user, account +): + root = Category.objects.create( + name='Raíz', owner=user, expense_type=Category.EXPENSE_TYPE_FIXED_ESSENTIAL + ) + Expense.objects.create( + owner=user, account=account, category=root, + amount=Decimal('1'), date=date(2024, 3, 1), + ) + baseline = _dashboard_queries(auth_client) + + # Más categorías anidadas y más gastos que las 7 filas de recent_expenses: + # ni la herencia del desglose ni el listado reciente deben pedir una + # consulta por fila. + parent = root + for i in range(10): + parent = Category.objects.create(name=f'Nivel {i}', owner=user, parent=parent) + Expense.objects.create( + owner=user, account=account, category=parent, + amount=Decimal('1'), date=date(2024, 3, 1), + ) + + assert _dashboard_queries(auth_client) == baseline diff --git a/expenses_manager/expenses/tests/test_smoke.py b/expenses_manager/expenses/tests/test_smoke.py index 6fa49ae..70d566a 100644 --- a/expenses_manager/expenses/tests/test_smoke.py +++ b/expenses_manager/expenses/tests/test_smoke.py @@ -104,6 +104,8 @@ DASHBOARD_CONTEXT_KEYS = { "chart_type", "by_category", "by_category_chart", + "expense_type_breakdown", + "expense_type_classified_count", "accounts_charts", # Filtros de periodo, cuenta y rango libre "period", diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index 5ce7ce6..e9ae973 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -33,6 +33,7 @@ urlpatterns = [ path('categories//edit/', views.category_edit, name='category_edit'), path('categories//delete/', views.category_delete, name='category_delete'), path('settings/', views.settings_index, name='settings_index'), + path('budget/', views.budget_blocks, name='budget_blocks'), 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'), diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 28c251d..1785598 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -13,6 +13,7 @@ from .models import ( Income, Goal, Transfer, + BudgetBlock, ) from .forms import ( ExpenseForm, @@ -24,11 +25,12 @@ from .forms import ( GoalForm, TransferForm, ExportForm, + BudgetBlockForm, ) from .csv_export import DIALECTS, EXPORTS, csv_filename, stream_csv from django.core.paginator import Paginator -from django.db.models import Sum, Count, Min, ProtectedError +from django.db.models import Sum, Count, Min, ProtectedError, Q from django.db.models.functions import ( ExtractMonth, ExtractYear, @@ -39,6 +41,7 @@ from django.db.models.functions import ( from django.contrib.auth.decorators import login_required from django.http import HttpResponseBadRequest, StreamingHttpResponse +from django.utils.formats import number_format from django.utils.http import url_has_allowed_host_and_scheme from django.shortcuts import get_object_or_404, render, redirect from django.urls import reverse @@ -144,6 +147,7 @@ def _category_tree(categories): rows.append({ "category": category, "depth": depth, + "expense_type": expense_type, "expense_type_label": labels.get(expense_type, ""), "expense_type_inherited": not category.expense_type, }) @@ -153,6 +157,106 @@ def _category_tree(categories): return rows +def _expense_type_breakdown(user, expenses): + """Suma `expenses` por el tipo de gasto efectivo de su categoría. + + Devuelve siempre las cinco filas (los cuatro tipos y "Sin clasificar" al + final), aunque alguna valga cero: así cada tipo conserva su color en el + gráfico. Lo que no tiene tipo, ni propio ni heredado, va a "Sin + clasificar" y nunca se descarta, para que la suma cuadre con el total. + + Devuelve también cuántas categorías tienen tipo propio (sin contar las + que lo heredan), para avisar cuando hay demasiado poco clasificado. + + Son dos consultas fijas: las categorías, de las que _category_tree saca el + tipo heredado, y los gastos agrupados por categoría.""" + tree = _category_tree(Category.objects.filter(owner=user)) + effective = {row["category"].id: row["expense_type"] for row in tree} + classified_count = sum(1 for row in tree if row["category"].expense_type) + + labels = dict(Category.EXPENSE_TYPE_CHOICES) + labels[""] = "Sin clasificar" + totals = dict.fromkeys(labels, Decimal("0")) + + for row in expenses.values("category").annotate(total=Sum("amount")): + totals[effective.get(row["category"], "")] += row["total"] + + rows = [ + {"expense_type": key, "label": labels[key], "total": total} + for key, total in totals.items() + ] + return rows, classified_count + + +def _savings_total(user, date_from, date_to): + """Ahorro neto del periodo, medido por los traspasos entre cuentas. + + No todos los traspasos cuentan igual: + - de una cuenta normal a una de ahorro: suma, es ahorrar; + - de una de ahorro a una normal: resta, es desahorro, y sacar dinero del + colchón no puede contar como ahorrar; + - entre dos de ahorro: ni suma ni resta. Mover dinero de una hucha a otra + no es ahorro nuevo, y contarlo inflaría el bloque cada vez que se + reorganizan cuentas. + + Solo cuenta el importe del traspaso; la comisión ya es un gasto normal. + Una sola consulta. `date_to` es exclusivo.""" + totals = Transfer.objects.filter( + owner=user, date__gte=date_from, date__lt=date_to + ).aggregate( + saved=Sum( + "amount", + filter=Q(to_account__is_savings=True, from_account__is_savings=False), + ), + withdrawn=Sum( + "amount", + filter=Q(from_account__is_savings=True, to_account__is_savings=False), + ), + ) + return (totals["saved"] or Decimal("0")) - (totals["withdrawn"] or Decimal("0")) + + +def _budget_block_proposal(user, today): + """Reparto propuesto a partir de los últimos 12 meses completos. + + Cada bloque es su parte del ingreso real del periodo, sin traspasos. Como + es la misma división para los dos lados, el bloque sobre el ingreso medio + mensual es lo mismo que el bloque sobre el ingreso total, así que da igual + si hay 12 meses de datos o menos. + + Devuelve None si no hubo ingresos: sin denominador no hay porcentaje. + Son cuatro consultas fijas (dos de _expense_type_breakdown, ingresos y + traspasos), crezca lo que crezca el número de categorías o de gastos.""" + date_to = today.replace(day=1) + date_from = date(*sub_months(date_to.year, date_to.month, 12), 1) + + income = _analysis_incomes(user).filter( + date__gte=date_from, date__lt=date_to + ).aggregate(total=Sum("amount"))["total"] + if not income: + return None + + expenses = _analysis_expenses(user).filter(date__gte=date_from, date__lt=date_to) + breakdown, _ = _expense_type_breakdown(user, expenses) + totals = {row["expense_type"]: row["total"] for row in breakdown} + totals[BudgetBlock.BLOCK_SAVING] = _savings_total(user, date_from, date_to) + + def pct(amount): + # Un desahorro neto daría un porcentaje negativo, que el formulario no + # admite: la propuesta se queda en 0 para ese bloque. + return max(amount / income * 100, Decimal("0")).quantize(Decimal("0.1")) + + return { + "date_from": date_from, + # El último día incluido, para enseñarlo en la pantalla. + "date_to": date_to - timedelta(days=1), + "blocks": {block: pct(totals[block]) for block, _ in BudgetBlock.BLOCK_CHOICES}, + # El gasto sin clasificar no va a ningún bloque. Se enseña aparte para + # que se entienda por qué la propuesta no llega al 100 %. + "unclassified": pct(totals[""]), + } + + def _without_transfers(movements): """Quita las patas de traspaso de un queryset de gastos o de ingresos. @@ -846,6 +950,12 @@ def dashboard(request): "category__name": cat_name, "total": total_cat }) + + # Sobre el mismo conjunto que los KPIs (periodo, cuenta y sin traspasos), + # así que sus cinco porciones suman kpi_total. + expense_type_breakdown, expense_type_classified_count = _expense_type_breakdown( + request.user, expenses_filtered + ) #Graphic # El eje se construye una sola vez (claves + etiquetas + agrupación) y las @@ -999,7 +1109,11 @@ def dashboard(request): goals = Goal.objects.filter(owner=request.user) # Recent expenses - recent_expenses = Expense.objects.filter(owner=request.user).order_by('-date', '-id')[:7] + recent_expenses = ( + Expense.objects.filter(owner=request.user) + .select_related("category", "account") + .order_by('-date', '-id')[:7] + ) # Send the data to the dashboard return render( @@ -1010,6 +1124,8 @@ def dashboard(request): "today": today, "by_category": by_category, "by_category_chart": by_category_chart, + "expense_type_breakdown": expense_type_breakdown, + "expense_type_classified_count": expense_type_classified_count, "chart_labels": chart_labels, "chart_data": chart_totals, # La otra serie del gráfico de ingresos contra gastos. Se manda una @@ -1738,6 +1854,54 @@ def settings_index(request): return render(request, "settings/index.html", {"active_menu": "settings"}) +@login_required +def budget_blocks(request): + proposal = _budget_block_proposal(request.user, date.today()) + + if request.method == "POST": + form = BudgetBlockForm(request.POST, user=request.user) + if form.is_valid(): + form.save() + messages.success(request, "Reparto guardado.") + return redirect("budget_blocks") + elif proposal and request.GET.get("propuesta"): + # "Usar valores calculados" solo precarga el formulario: no se guarda + # nada hasta que el usuario pulsa Guardar. + form = BudgetBlockForm(user=request.user, initial=proposal["blocks"]) + else: + form = BudgetBlockForm(user=request.user) + + proposal_total = None + if proposal: + for block, value in proposal["blocks"].items(): + # number_format para que salga con coma decimal, como en las + # plantillas; con %s quedaría "25.0" al lado de "25,0". + form.fields[block].help_text = "Calculado: %s %%" % number_format(value) + # Con más gasto que ingreso en el periodo la propuesta pasa de 100 y, + # aplicada tal cual, la rechazaría la validación al guardar: la + # plantilla lo avisa junto al botón. + proposal_total = sum(proposal["blocks"].values()) + + # Lo que queda sin asignar con el reparto guardado, no con lo que se esté + # tecleando: es lo que vale de verdad mientras no se guarde. + assigned = BudgetBlock.objects.filter(owner=request.user).aggregate( + total=Sum("percentage") + )["total"] or Decimal("0") + + return render( + request, + "settings/budget_blocks.html", + { + "active_menu": "settings", + "form": form, + "proposal": proposal, + "proposal_total": proposal_total, + "assigned": assigned, + "unassigned": Decimal("100") - assigned, + }, + ) + + @login_required def transfer_list(request): transfers = Transfer.objects.filter(owner=request.user).select_related(