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/templates/expenses/account_list.html b/expenses_manager/expenses/templates/expenses/account_list.html index c94dbc3..8a9af1e 100644 --- a/expenses_manager/expenses/templates/expenses/account_list.html +++ b/expenses_manager/expenses/templates/expenses/account_list.html @@ -14,6 +14,7 @@
No hay cuentas
Añade la primeraQué 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 %} + + + + +{% 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.
+ +Decide qué parte de tus ingresos va a cada tipo de gasto y al ahorro.
+ +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/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/