From 982f654cb5ff1b097784872950cc54f710818eb2 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 09:23:24 +0200 Subject: [PATCH 1/4] Ignore graphify-out/ --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 45878d3..841b644 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,7 @@ cython_debug/ #.idea/ staticfiles/ -*.bak \ No newline at end of file +*.bak + +# graphify +graphify-out/ \ No newline at end of file -- 2.45.2 From 4167338a6ae23ded1059d79ce2fbbc086157ea98 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 13:37:47 +0200 Subject: [PATCH 2/4] Add goal-movement links and collection goal type --- ...d_expenses_goal_linked_incomes_and_more.py | 28 ++++ expenses_manager/expenses/models.py | 128 ++++++++++++++++-- 2 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py diff --git a/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py b/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py new file mode 100644 index 0000000..4602438 --- /dev/null +++ b/expenses_manager/expenses/migrations/0017_goal_linked_expenses_goal_linked_incomes_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.10 on 2026-10-08 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0016_alter_transfer_expense_leg_alter_transfer_income_leg'), + ] + + operations = [ + migrations.AddField( + model_name='goal', + name='linked_expenses', + field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.expense', verbose_name='Gastos vinculados'), + ), + migrations.AddField( + model_name='goal', + name='linked_incomes', + field=models.ManyToManyField(blank=True, related_name='goals', to='expenses.income', verbose_name='Ingresos vinculados'), + ), + migrations.AlterField( + model_name='goal', + name='kind', + field=models.CharField(choices=[('payment', 'Pago / deuda'), ('budget', 'Presupuesto'), ('saving', 'Ahorro'), ('collection', 'Cobro pendiente')], default='payment', max_length=10), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index b6d763b..6ac2249 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -3,7 +3,7 @@ from decimal import Decimal from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models, transaction from django.conf import settings -from django.db.models import Sum +from django.db.models import Q, Sum from functools import cached_property from django.utils.text import slugify from django.db.models.functions import ExtractMonth, Lower @@ -551,17 +551,47 @@ class FuelEntry(models.Model): return None +def _deduped_total(movements): + """Suma importes sin que el JOIN del M2M cuente la misma fila dos veces. + + Un aggregate() sobre un queryset que lleva el JOIN de la vinculación dentro + suma cada movimiento tantas veces como objetivos tenga vinculados, y + .distinct() no lo arregla: el DISTINCT se aplica a las filas del SELECT + final y en un aggregate no hay filas que deduplicar, porque el JOIN ya + multiplicó antes de sumar. Sum("amount", distinct=True) es todavía peor, + porque deduplica por el valor del importe: dos gastos distintos de 10 € se + sumarían como uno. + + Así que primero los ids (ahí el DISTINCT sí vale) y luego la suma. El + `pk__in` con un queryset dentro es una subconsulta, no un segundo viaje. + """ + ids = movements.values("pk").distinct() + total = movements.model.objects.filter(pk__in=ids).aggregate( + total=Sum("amount") + )["total"] + + return total or Decimal("0") + + class Goal(models.Model): KIND_PAYMENT = "payment" KIND_BUDGET = "budget" KIND_SAVING = "saving" + KIND_COLLECTION = "collection" KIND_CHOICES = [ (KIND_PAYMENT, "Pago / deuda"), (KIND_BUDGET, "Presupuesto"), (KIND_SAVING, "Ahorro"), + (KIND_COLLECTION, "Cobro pendiente"), ] + # Qué objetivos admiten vincular cada tipo de movimiento. Las dos vías de + # marcado (el formulario del movimiento y la pantalla del objetivo) leen de + # aquí: si cada una lo decidiera por su cuenta, acabarían divergiendo. + KINDS_LINKING_EXPENSES = (KIND_PAYMENT, KIND_BUDGET) + KINDS_LINKING_INCOMES = (KIND_COLLECTION,) + PERIOD_NONE = "none" PERIOD_MONTH = "month" PERIOD_YEAR = "year" @@ -604,6 +634,57 @@ class Goal(models.Model): created_at = models.DateTimeField(auto_now_add=True) show_on_home = models.BooleanField(default=False) + # Movimientos sueltos vinculados a mano. Conviven con la selección por + # categoría, no la sustituyen: un objetivo puede tirar de categoría, de + # movimientos sueltos, o de los dos a la vez. + # + # blank=True y no null: un M2M no admite null. Borrar el objetivo limpia la + # tabla intermedia y no toca los movimientos, que es lo que se quiere. + linked_expenses = models.ManyToManyField( + "Expense", blank=True, related_name="goals", verbose_name="Gastos vinculados" + ) + linked_incomes = models.ManyToManyField( + "Income", blank=True, related_name="goals", verbose_name="Ingresos vinculados" + ) + + @property + def links_incomes(self): + """Si este objetivo se mide con ingresos en vez de con gastos.""" + return self.kind in self.KINDS_LINKING_INCOMES + + @property + def links(self): + """El M2M que le toca a este objetivo, para no repetir el if por ahí.""" + return self.linked_incomes if self.links_incomes else self.linked_expenses + + @property + def accepts_links(self): + """Si vincularle movimientos significa algo. + + El ahorro no: se mide con el saldo de su cuenta, así que un gasto + vinculado ahí quedaría guardado y no contaría en el progreso. + """ + return self.kind in self.KINDS_LINKING_EXPENSES + self.KINDS_LINKING_INCOMES + + def candidate_movements(self): + """Movimientos que este objetivo puede vincular. + + Único sitio donde se decide qué es vinculable; lo usan las dos vías de + marcado. Fuera las patas de un traspaso: mover dinero entre cuentas + propias no es ni un pago ni un cobro. La comisión sí entra, porque es + gasto real (va con is_transfer=False) y cuenta en los análisis. + """ + model = Income if self.links_incomes else Expense + movements = model.objects.filter(owner_id=self.owner_id).exclude( + is_transfer=True + ) + + start = self._period_start() + if start: + movements = movements.filter(date__gte=start) + + return movements + def _period_start(self): """Fecha desde la que se cuenta el progreso, o None si es todo.""" if self.kind == self.KIND_BUDGET: @@ -632,25 +713,46 @@ class Goal(models.Model): # así que no filtres is_transfer en esta rama. return self.account.current_balance() if self.account else Decimal("0") - if not self.category: - return Decimal("0") + if self.kind == self.KIND_COLLECTION: + # Un cobro pendiente mide lo que te han devuelto, así que suma los + # ingresos vinculados a mano. Aquí no hay selección por categoría + # que valga: Income no tiene categoría, que es justo el motivo de + # que exista la vinculación. + incomes = Income.objects.filter(goals=self, owner_id=self.owner_id).exclude( + is_transfer=True + ) - if self.include_subcategories: - category_ids = self.category.descendant_ids() - else: - category_ids = [self.category_id] + return _deduped_total(self._since_start(incomes)) + + category_ids = [] + # Por _id y no por el objeto: mirar self.category traería la categoría + # de la base de datos, una consulta por objetivo al pintar una lista. + if self.category_id: + if self.include_subcategories: + category_ids = self.category.descendant_ids() + else: + category_ids = [self.category_id] + + # Un solo queryset con un OR, y no dos sumas por separado: un gasto que + # esté en la categoría del objetivo Y además vinculado a mano se + # contaría dos veces. No daría error, daría un número mal. + scope = Q(goals=self) + if category_ids: + scope |= Q(category_id__in=category_ids) # Pago y presupuesto miden gasto real, así que los traspasos entre # cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta. - expenses = Expense.objects.filter( - owner=self.owner, category_id__in=category_ids - ).exclude(is_transfer=True) + expenses = Expense.objects.filter(scope, owner_id=self.owner_id).exclude( + is_transfer=True + ) + return _deduped_total(self._since_start(expenses)) + + def _since_start(self, movements): + """Acota al periodo del objetivo. El cobro pendiente lo necesita igual.""" start = self._period_start() - if start: - expenses = expenses.filter(date__gte=start) - return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0") + return movements.filter(date__gte=start) if start else movements def percentage(self): if not self.target_amount: -- 2.45.2 From a5f1c7b59053288b3985ee8f4c904b1e5c3a04dc Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 13:37:47 +0200 Subject: [PATCH 3/4] Add linking UI for goals from movement form and goal page --- expenses_manager/expenses/forms.py | 56 +++++++++- .../templates/expenses/_form_fields.html | 2 +- .../expenses/templates/goals/form.html | 47 +++++++- .../expenses/templates/goals/links.html | 102 ++++++++++++++++++ .../expenses/templates/goals/list.html | 3 + expenses_manager/expenses/urls.py | 1 + expenses_manager/expenses/views.py | 65 ++++++++++- 7 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 expenses_manager/expenses/templates/goals/links.html diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 0cfdb77..6442e32 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -25,7 +25,43 @@ def _hide_is_transfer_when_editing(form): form.fields.pop("is_transfer", None) -class ExpenseForm(forms.ModelForm): +class _GoalLinksMixin: + """Campo "Objetivos" en el formulario de un movimiento. + + `goals` es el lado inverso de Goal.linked_expenses / linked_incomes, y un + ModelForm no guarda relaciones inversas: hay que poblarlo al abrir y + escribirlo a mano al guardar. Lo comparten gastos e ingresos para que qué + objetivo admite cada movimiento se decida en un solo sitio + (Goal.KINDS_LINKING_*, en el modelo). + """ + + goal_kinds = () + + def setup_goal_links(self, user): + self.fields["goals"] = forms.ModelMultipleChoiceField( + queryset=Goal.objects.filter(owner=user, kind__in=self.goal_kinds), + required=False, + label="Objetivos", + widget=forms.CheckboxSelectMultiple(), + help_text="Este movimiento cuenta en los objetivos que marques.", + ) + + if self.instance.pk: + self.initial["goals"] = list( + self.instance.goals.values_list("pk", flat=True) + ) + + def _save_m2m(self): + super()._save_m2m() + + # Si el formulario se construyó sin usuario no hay campo, y entonces no + # se toca nada: un set() a ciegas borraría las vinculaciones que ya haya. + if "goals" in self.cleaned_data: + self.instance.goals.set(self.cleaned_data["goals"]) + + +class ExpenseForm(_GoalLinksMixin, forms.ModelForm): + goal_kinds = Goal.KINDS_LINKING_EXPENSES def __init__(self, *args, **kwargs): # Get user @@ -40,6 +76,7 @@ class ExpenseForm(forms.ModelForm): owner=user, active=True ) self.fields["date"].input_formats = ["%Y-%m-%d"] + self.setup_goal_links(user) _hide_is_transfer_when_editing(self) @@ -73,7 +110,9 @@ class AccountForm(forms.ModelForm): fields = ["name", "initial_balance", "active"] -class IncomeForm(forms.ModelForm): +class IncomeForm(_GoalLinksMixin, forms.ModelForm): + goal_kinds = Goal.KINDS_LINKING_INCOMES + class Meta: model = Income fields = ["account", "name", "amount", "date", "is_transfer"] @@ -86,6 +125,7 @@ class IncomeForm(forms.ModelForm): owner=user, active=True, ) + self.setup_goal_links(user) _hide_is_transfer_when_editing(self) @@ -225,6 +265,18 @@ class GoalForm(forms.ModelForm): "Selecciona la cuenta donde se acumula el ahorro.", ) cleaned["category"] = None + + if kind == Goal.KIND_COLLECTION: + # Un cobro pendiente se mide solo con los ingresos que se le + # vinculen, así que no lleva categoría ni cuenta; la fecha de inicio + # sí, porque es la que acota desde cuándo cuentan. + if not cleaned.get("start_date"): + self.add_error( + "start_date", + "Indica desde cuándo se cuentan los cobros.", + ) + cleaned["category"] = None + cleaned["account"] = None if kind == Goal.KIND_BUDGET: if cleaned.get("period") == Goal.PERIOD_NONE: diff --git a/expenses_manager/expenses/templates/expenses/_form_fields.html b/expenses_manager/expenses/templates/expenses/_form_fields.html index d4a3cd3..68792d5 100644 --- a/expenses_manager/expenses/templates/expenses/_form_fields.html +++ b/expenses_manager/expenses/templates/expenses/_form_fields.html @@ -1,6 +1,6 @@ {% for field in form %}
- {% if field.name == "tags" %} + {% if field.name == "tags" or field.name == "goals" %} {{ field.label_tag }}
{% for checkbox in field %} diff --git a/expenses_manager/expenses/templates/goals/form.html b/expenses_manager/expenses/templates/goals/form.html index 531e076..11c5d62 100644 --- a/expenses_manager/expenses/templates/goals/form.html +++ b/expenses_manager/expenses/templates/goals/form.html @@ -25,7 +25,8 @@ Para ponerte un límite de gasto que no quieres superar. El progreso se reinicia cada mes o cada año, según el periodo que elijas. Aquí acercarse al 100% es un aviso, no un logro: la barra pasa a - naranja al llegar al 80% y a rojo si te pasas del límite. + naranja al llegar al 80% y a rojo si te pasas del límite. Cuenta + los gastos de su categoría y además los que le vincules a mano.

Ahorro

@@ -35,6 +36,46 @@ categoría.

+

Cobro pendiente

+

+ El inverso del de pago: para dinero que te tienen que devolver y vas + cobrando a plazos. El progreso es la suma de los ingresos que le + vincules desde la fecha de inicio, y llegar al 100% es haberlo + cobrado todo. No lleva categoría ni cuenta, porque los ingresos no + tienen categoría: se mide solo con los ingresos que marques. +

+ +

Vincular movimientos

+

+ Además de contar una categoría entera, un objetivo puede contar + gastos o ingresos concretos. Se marcan de dos maneras: con el campo + Objetivos del formulario del gasto o del ingreso, o + desde Vincular en la lista de objetivos, que permite + marcar varios de golpe. +

+

+ Lo admiten tres tipos, y cada uno vincula lo que mide: +

+
    +
  • Pago / deuda y presupuesto — gastos.
  • +
  • Cobro pendiente — ingresos.
  • +
  • + Ahorro — no admite vinculación: se mide con el + saldo de su cuenta, así que un gasto marcado ahí no contaría. +
  • +
+

+ La categoría y las vinculaciones conviven: un presupuesto de ocio + puede contar su categoría y además un gasto suelto de otra que + quieras que compute. Un gasto que esté en la categoría y también + vinculado a mano cuenta una sola vez. +

+

+ Los dos movimientos de un traspaso no se pueden vincular (mover + dinero entre tus cuentas no es un gasto ni un cobro), pero su + comisión sí, porque es gasto real. +

+

Los demás campos

  • @@ -51,8 +92,8 @@
  • Start date — desde cuándo se cuentan los gastos - en un pago. En un presupuesto no se usa: manda el reinicio del - periodo. + en un pago, o los ingresos en un cobro pendiente. En un + presupuesto no se usa: manda el reinicio del periodo.
  • Period — solo para presupuesto: cada cuánto diff --git a/expenses_manager/expenses/templates/goals/links.html b/expenses_manager/expenses/templates/goals/links.html new file mode 100644 index 0000000..fe7d261 --- /dev/null +++ b/expenses_manager/expenses/templates/goals/links.html @@ -0,0 +1,102 @@ +{% extends "expenses/base.html" %} + +{% block title %} + Vincular movimientos +{% endblock %} + +{% block content %} +

    Vincular movimientos

    + +

    + Objetivo: {{ goal.name }} ({{ goal.get_kind_display|lower }}). + {% if goal.links_incomes %} + Marca los ingresos que cuentan como cobrados. + {% else %} + Marca los gastos que cuentan en este objetivo. + {% endif %} +

    + +

    + Vinculado ahora mismo: {{ linked_total|floatformat:2 }}€ + de {{ goal.target_amount|floatformat:2 }}€. +

    + +{% if goal.start_date %} +

    Solo se listan movimientos desde el {{ goal.start_date|date:"d/m/Y" }}.

    +{% endif %} + +
    + {% csrf_token %} + +
    + + + + + + + + + + + + {% for movement in page_obj %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
    FechaImporteCuentaConcepto
    + {# shown viaja con cada fila de la página: así al guardar solo #} + {# se toca lo que había aquí y no lo marcado en otras páginas. #} + + + + {{ movement.date|date:"d/m/Y" }}{{ movement.amount|floatformat:2 }}€{{ movement.account.name }} + {% if goal.links_incomes %} + {{ movement.name }} + {% else %} + {{ movement.description|default:movement.category.name }} + {% endif %} +
    +

    No hay movimientos que vincular

    +
    +
    + +
    + + Volver +
    +
    + + + +{% endblock %} diff --git a/expenses_manager/expenses/templates/goals/list.html b/expenses_manager/expenses/templates/goals/list.html index 66a4ee8..4f0386e 100644 --- a/expenses_manager/expenses/templates/goals/list.html +++ b/expenses_manager/expenses/templates/goals/list.html @@ -76,6 +76,9 @@ Editar + {% if goal.accepts_links %} + Vincular + {% endif %} Eliminar diff --git a/expenses_manager/expenses/urls.py b/expenses_manager/expenses/urls.py index 252b3d7..5ce7ce6 100644 --- a/expenses_manager/expenses/urls.py +++ b/expenses_manager/expenses/urls.py @@ -39,5 +39,6 @@ urlpatterns = [ path('goals/new/', views.goal_create, name='goal_create'), path('goals//edit/', views.goal_edit, name='goal_edit'), path('goals//delete/', views.goal_delete, name='goal_delete'), + path('goals//links/', views.goal_links, name='goal_links'), path('password_help', TemplateView.as_view(template_name="registration/password_help.html"), name="password_help"), ] \ No newline at end of file diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 560d191..30fad4e 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1107,6 +1107,9 @@ def income_create(request): income = form.save(commit=False) income.owner = request.user income.save() + # Hace falta por el campo de objetivos: sin esto la vinculación + # hecha al crear se perdería sin avisar. + form.save_m2m() messages.success(request, "Ingreso creado correctamente.") return redirect("income_list") else: @@ -1486,7 +1489,9 @@ def category_delete(request, pk): @login_required def goal_list(request): - goals = Goal.objects.filter(owner=request.user) + goals = Goal.objects.filter(owner=request.user).select_related( + "category", "account" + ) selected_kind = request.GET.get("kind") or "" if selected_kind in dict(Goal.KIND_CHOICES): @@ -1578,6 +1583,64 @@ def goal_delete(request, pk): ) +@login_required +def goal_links(request, pk): + """Vincular movimientos a un objetivo de golpe, desde su ficha. + + La otra vía de marcado es el campo "Objetivos" del formulario del + movimiento. Las dos escriben en la misma tabla y las dos preguntan a + Goal.candidate_movements() qué es vinculable. + """ + goal = get_object_or_404(Goal, pk=pk, owner=request.user) + + if not goal.accepts_links: + messages.error( + request, + "Un objetivo de ahorro se mide con el saldo de su cuenta, así que no " + "se le vinculan movimientos.", + ) + return redirect("goal_list") + + candidates = goal.candidate_movements() + + if request.method == "POST": + shown = {int(v) for v in request.POST.getlist("shown") if v.isdigit()} + checked = {int(v) for v in request.POST.getlist("movements") if v.isdigit()} + + # Se parte de lo que de verdad podía marcarse en la página enviada: así + # un id de otro usuario o de un movimiento no vinculable se cae solo. + allowed = set( + candidates.filter(pk__in=shown).values_list("pk", flat=True) + ) + # Solo se toca lo de esa página: con paginación, un set() a secas + # desvincularía lo marcado en las demás. + goal.links.add(*(checked & allowed)) + goal.links.remove(*(allowed - checked)) + + messages.success(request, "Movimientos vinculados actualizados.") + return redirect("goal_links", pk=goal.pk) + + movements = candidates.select_related("account").order_by("-date", "-id") + paginator = Paginator(movements, 10) + page_obj = paginator.get_page(request.GET.get("page")) + + return render( + request, + "goals/links.html", + { + "active_menu": "settings", + "goal": goal, + "page_obj": page_obj, + "linked_ids": set(goal.links.values_list("pk", flat=True)), + # Solo lo vinculado a mano, que es lo que se está tocando aquí; el + # progreso del objetivo puede llevar además los gastos de su + # categoría. + "linked_total": goal.links.aggregate(total=Sum("amount"))["total"] + or Decimal("0"), + }, + ) + + @login_required def settings_index(request): return render(request, "settings/index.html", {"active_menu": "settings"}) -- 2.45.2 From c50d41656085444906c3181930cf93dab0fc3256 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Tue, 13 Oct 2026 13:37:48 +0200 Subject: [PATCH 4/4] Add tests for goal links and collection goals --- .../expenses/tests/test_goal_links.py | 749 ++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_goal_links.py diff --git a/expenses_manager/expenses/tests/test_goal_links.py b/expenses_manager/expenses/tests/test_goal_links.py new file mode 100644 index 0000000..e33eca3 --- /dev/null +++ b/expenses_manager/expenses/tests/test_goal_links.py @@ -0,0 +1,749 @@ +"""Vinculación de movimientos a objetivos y el tipo "cobro pendiente". + +El test central es el del doble conteo: con la selección por categoría y la +vinculación a mano conviviendo, un gasto que esté en las dos tiene que contar +una sola vez. No da error si se cuenta dos veces, da un número mal, que es peor. +""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.db import connection +from django.test.utils import CaptureQueriesContext +from django.urls import reverse + +from expenses.forms import ExpenseForm, GoalForm, IncomeForm +from expenses.models import Account, Category, Expense, Goal, Income, Transfer + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def savings(user): + return Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + + +def make_payment_goal(user, category=None, target="1000", start=date(2024, 1, 1)): + return Goal.objects.create( + owner=user, + name="Pago", + kind=Goal.KIND_PAYMENT, + category=category, + include_subcategories=False, + start_date=start, + target_amount=Decimal(target), + ) + + +def make_budget_goal(user, category, target="100", period=Goal.PERIOD_MONTH): + return Goal.objects.create( + owner=user, + name="Presupuesto", + kind=Goal.KIND_BUDGET, + category=category, + include_subcategories=False, + period=period, + target_amount=Decimal(target), + ) + + +def make_collection_goal(user, target="1000", start=date(2024, 1, 1)): + return Goal.objects.create( + owner=user, + name="Cobro", + kind=Goal.KIND_COLLECTION, + start_date=start, + target_amount=Decimal(target), + ) + + +def make_expense(user, account, category, amount="10", on=date(2024, 5, 11)): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + ) + + +def make_income(user, account, amount="10", on=date(2024, 5, 11), name="Devolución"): + return Income.objects.create( + owner=user, account=account, name=name, amount=Decimal(amount), date=on + ) + + +# -------------------------------------------------------------------------- +# El doble conteo: el test central de la fase +# -------------------------------------------------------------------------- + + +def test_an_expense_in_the_category_and_linked_by_hand_counts_once( + user, account, category +): + expense = make_expense(user, account, category, amount="100") + goal = make_payment_goal(user, category=category) + + goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("100") + + +def test_the_category_and_the_links_add_up_without_overlapping( + user, account, category +): + other_category = Category.objects.create(owner=user, name="Otra") + in_category = make_expense(user, account, category, amount="100") + linked_outside = make_expense(user, account, other_category, amount="30") + goal = make_payment_goal(user, category=category) + + # El de la categoría también vinculado a mano, para que el solape exista. + goal.linked_expenses.add(in_category, linked_outside) + + assert goal.progress == Decimal("130") + + +def test_a_linked_expense_counted_in_several_goals_is_not_inflated( + user, account, category +): + expense = make_expense(user, account, category, amount="100") + goal = make_payment_goal(user, category=category) + other_goal = make_payment_goal(user, category=category) + + # El JOIN del M2M devuelve una fila por objetivo vinculado: si se suma sin + # deduplicar, este gasto cuenta tantas veces como objetivos tenga. + goal.linked_expenses.add(expense) + other_goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("100") + + +def test_a_goal_with_links_and_no_category_still_measures(user, account, category): + expense = make_expense(user, account, category, amount="40") + goal = make_payment_goal(user, category=None) + + goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("40") + + +def test_links_respect_the_start_date(user, account, category): + old = make_expense(user, account, category, amount="99", on=date(2023, 5, 1)) + goal = make_payment_goal(user, category=None, start=date(2024, 1, 1)) + + goal.linked_expenses.add(old) + + assert goal.progress == Decimal("0") + + +# -------------------------------------------------------------------------- +# Presupuesto: el caso con más riesgo de duplicar, porque siempre tiene +# categoría y además admite vinculaciones +# -------------------------------------------------------------------------- + + +def test_a_budget_counts_a_linked_expense_of_its_own_category_once( + user, account, category +): + # Un presupuesto siempre lleva categoría, así que el solape con la + # vinculación a mano es el caso normal, no el raro. + expense = make_expense(user, account, category, amount="30", on=date.today()) + goal = make_budget_goal(user, category, target="100") + + goal.linked_expenses.add(expense) + + assert goal.progress == Decimal("30") + + +def test_a_budget_adds_a_linked_expense_from_another_category( + user, account, category +): + other_category = Category.objects.create(owner=user, name="Ocio") + make_expense(user, account, category, amount="30", on=date.today()) + outside = make_expense(user, account, other_category, amount="20", on=date.today()) + goal = make_budget_goal(user, category, target="100") + + # El caso que motiva vincular en un presupuesto: un gasto de otra categoría + # que quieres que compute aquí. + goal.linked_expenses.add(outside) + + assert goal.progress == Decimal("50") + + +def test_a_budget_blown_by_a_linked_expense_is_still_flagged(user, account, category): + other_category = Category.objects.create(owner=user, name="Ocio") + outside = make_expense(user, account, other_category, amount="120", on=date.today()) + goal = make_budget_goal(user, category, target="100") + + goal.linked_expenses.add(outside) + + assert goal.progress == Decimal("120") + assert goal.is_exceeded() is True + assert goal.progress_state() == "danger" + + +def test_a_budget_accepts_links(user, category): + assert make_budget_goal(user, category).accepts_links is True + + +def test_only_the_budget_inverts_the_criterion(user, account, category): + # Pasado del objetivo: el presupuesto lo marca como excedido, el pago lo + # considera conseguido. Solo el presupuesto invierte el criterio. + over_budget = make_budget_goal(user, category, target="100") + over_budget.linked_expenses.add( + make_expense(user, account, category, amount="120", on=date.today()) + ) + over_payment = make_payment_goal(user, category=None, target="100") + over_payment.linked_expenses.add( + make_expense(user, account, category, amount="120") + ) + + assert over_budget.is_exceeded() is True + assert over_budget.progress_state() == "danger" + assert over_payment.is_exceeded() is False + assert over_payment.progress_state() == "complete" + + +def test_a_budget_at_eighty_percent_warns(user, account, category): + goal = make_budget_goal(user, category, target="100") + goal.linked_expenses.add( + make_expense(user, account, category, amount="80", on=date.today()) + ) + + assert goal.progress_state() == "warning" + assert goal.is_exceeded() is False + + +def test_a_linked_expense_outside_the_budget_period_does_not_count( + user, account, category +): + other_category = Category.objects.create(owner=user, name="Ocio") + old = make_expense( + user, account, other_category, amount="50", on=date(2020, 1, 15) + ) + goal = make_budget_goal(user, category, target="100") + + # El presupuesto se reinicia cada mes: vincular algo viejo no lo resucita. + goal.linked_expenses.add(old) + + assert goal.progress == Decimal("0") + + +# -------------------------------------------------------------------------- +# Cobro pendiente +# -------------------------------------------------------------------------- + + +def test_a_collection_goal_sums_its_linked_incomes(user, account): + income = make_income(user, account, amount="250") + goal = make_collection_goal(user) + + goal.linked_incomes.add(income) + + assert goal.progress == Decimal("250") + + +def test_an_income_outside_the_collection_goal_does_not_count(user, account): + make_income(user, account, amount="250") + goal = make_collection_goal(user) + + assert goal.progress == Decimal("0") + + +def test_a_collection_goal_respects_the_start_date(user, account): + old = make_income(user, account, amount="250", on=date(2023, 1, 1)) + goal = make_collection_goal(user, start=date(2024, 1, 1)) + + goal.linked_incomes.add(old) + + assert goal.progress == Decimal("0") + + +def test_a_collection_goal_at_half_way_is_not_flagged(user, account): + goal = make_collection_goal(user, target="100") + goal.linked_incomes.add(make_income(user, account, amount="50")) + + assert goal.percentage() == Decimal("50") + # Acercarse al 100% es bueno aquí, como en pago y ahorro: solo el + # presupuesto invierte el criterio. + assert goal.progress_state() == "ok" + assert goal.is_exceeded() is False + + +def test_a_fully_collected_goal_is_complete(user, account): + goal = make_collection_goal(user, target="100") + goal.linked_incomes.add(make_income(user, account, amount="100")) + + assert goal.progress_state() == "complete" + assert goal.is_exceeded() is False + + +def test_the_new_kind_is_offered_in_the_goal_list_filter(auth_client, user): + make_collection_goal(user) + make_payment_goal(user) + + response = auth_client.get(reverse("goal_list"), {"kind": Goal.KIND_COLLECTION}) + + assert response.status_code == 200 + assert [g.kind for g in response.context["goals"]] == [Goal.KIND_COLLECTION] + + +def test_a_collection_goal_needs_a_start_date(user): + form = GoalForm( + { + "name": "Cobro", + "kind": Goal.KIND_COLLECTION, + "target_amount": "100", + "period": Goal.PERIOD_NONE, + }, + user=user, + ) + + assert not form.is_valid() + assert "start_date" in form.errors + + +def test_a_collection_goal_needs_neither_category_nor_account(user, category): + form = GoalForm( + { + "name": "Cobro", + "kind": Goal.KIND_COLLECTION, + "target_amount": "100", + "start_date": "2024-01-01", + "period": Goal.PERIOD_NONE, + }, + user=user, + ) + + assert form.is_valid(), form.errors + goal = form.save(commit=False) + assert goal.category is None + assert goal.account is None + + +# -------------------------------------------------------------------------- +# Qué es vinculable: las patas del traspaso no, la comisión sí +# -------------------------------------------------------------------------- + + +def test_a_transfer_leg_is_not_a_candidate(user, account, savings, category): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + ) + transfer.sync() + goal = make_payment_goal(user, category=category) + + candidates = list(goal.candidate_movements()) + + # Mover dinero entre cuentas propias no es ni un pago ni un cobro. + assert transfer.expense_leg not in candidates + + +def test_the_income_leg_is_not_a_candidate_for_a_collection_goal( + user, account, savings +): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + ) + transfer.sync() + goal = make_collection_goal(user) + + assert transfer.income_leg not in list(goal.candidate_movements()) + + +def test_the_transfer_fee_is_a_candidate(user, account, savings, category): + transfer = Transfer( + owner=user, + date=date(2024, 5, 11), + amount=Decimal("400"), + from_account=account, + to_account=savings, + fee_amount=Decimal("1.50"), + ) + transfer.sync() + goal = make_payment_goal(user, category=category) + + # La comisión es gasto real: va con is_transfer=False y cuenta en análisis. + assert transfer.fee_expense in list(goal.candidate_movements()) + + +def test_a_saving_goal_does_not_offer_linking(user, savings): + goal = Goal.objects.create( + owner=user, + name="Ahorro", + kind=Goal.KIND_SAVING, + account=savings, + target_amount=Decimal("100"), + ) + + assert goal.accepts_links is False + + +def test_the_linking_screen_turns_away_a_saving_goal(auth_client, user, savings): + goal = Goal.objects.create( + owner=user, + name="Ahorro", + kind=Goal.KIND_SAVING, + account=savings, + target_amount=Decimal("100"), + ) + + response = auth_client.get(reverse("goal_links", args=[goal.pk])) + + assert response.status_code == 302 + + +# -------------------------------------------------------------------------- +# Vía 1: el formulario del movimiento +# -------------------------------------------------------------------------- + + +def test_the_expense_form_only_offers_goals_that_take_expenses( + user, savings, category +): + payment = make_payment_goal(user, category=category) + budget = make_budget_goal(user, category) + collection = make_collection_goal(user) + saving = Goal.objects.create( + owner=user, + name="Ahorro", + kind=Goal.KIND_SAVING, + account=savings, + target_amount=Decimal("100"), + ) + + offered = list(ExpenseForm(user=user).fields["goals"].queryset) + + assert payment in offered + assert budget in offered + assert collection not in offered + assert saving not in offered + + +def test_the_goal_list_offers_linking_on_a_budget(auth_client, user, category): + goal = make_budget_goal(user, category) + + response = auth_client.get(reverse("goal_list")) + + assert reverse("goal_links", args=[goal.pk]) in response.content.decode() + + +def test_the_income_form_only_offers_collection_goals(user, category): + collection = make_collection_goal(user) + payment = make_payment_goal(user, category=category) + + offered = list(IncomeForm(user=user).fields["goals"].queryset) + + assert collection in offered + assert payment not in offered + + +def test_the_expense_form_does_not_offer_another_users_goals( + user, category, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + theirs = make_payment_goal(intruder, category=None) + + assert theirs not in list(ExpenseForm(user=user).fields["goals"].queryset) + + +def test_creating_an_expense_links_it_to_the_chosen_goal( + auth_client, user, account, category +): + goal = make_payment_goal(user, category=None) + + response = auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "75", + "category": category.pk, + "account": account.pk, + "description": "Compra", + "goals": [goal.pk], + }, + ) + + assert response.status_code == 302 + assert goal.progress == Decimal("75") + + +def test_creating_an_income_links_it_to_the_chosen_goal(auth_client, user, account): + goal = make_collection_goal(user) + + response = auth_client.post( + reverse("income_create"), + { + "date": "2024-05-11", + "amount": "75", + "account": account.pk, + "name": "Devolución", + "goals": [goal.pk], + }, + ) + + assert response.status_code == 302 + assert goal.progress == Decimal("75") + + +def test_editing_an_expense_can_unlink_it(auth_client, user, account, category): + expense = make_expense(user, account, category, amount="75") + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + auth_client.post( + reverse("expense_edit", args=[expense.pk]), + { + "date": "2024-05-11", + "amount": "75", + "category": category.pk, + "account": account.pk, + "description": "", + # Sin "goals": se queda sin vincular. + }, + ) + + assert goal.linked_expenses.count() == 0 + + +def test_the_expense_form_shows_the_current_links_when_editing( + user, account, category +): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + form = ExpenseForm(instance=expense, user=user) + + assert form.initial["goals"] == [goal.pk] + + +# -------------------------------------------------------------------------- +# Vía 2: la pantalla del objetivo +# -------------------------------------------------------------------------- + + +def test_the_linking_screen_lists_the_candidates(auth_client, user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + + response = auth_client.get(reverse("goal_links", args=[goal.pk])) + + assert response.status_code == 200 + assert expense in list(response.context["page_obj"]) + + +def test_linking_from_the_goal_screen_saves(auth_client, user, account, category): + expense = make_expense(user, account, category, amount="60") + goal = make_payment_goal(user, category=None) + + auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [expense.pk], "movements": [expense.pk]}, + ) + + assert list(goal.linked_expenses.all()) == [expense] + + +def test_both_ways_of_linking_end_up_the_same(auth_client, user, account, category): + first = make_expense(user, account, category, amount="60") + second = make_expense(user, account, category, amount="60") + by_screen = make_payment_goal(user, category=None) + by_form = make_payment_goal(user, category=None) + + auth_client.post( + reverse("goal_links", args=[by_screen.pk]), + {"shown": [first.pk], "movements": [first.pk]}, + ) + auth_client.post( + reverse("expense_edit", args=[second.pk]), + { + "date": second.date.isoformat(), + "amount": "60", + "category": category.pk, + "account": account.pk, + "description": "", + "goals": [by_form.pk], + }, + ) + + assert by_screen.progress == by_form.progress == Decimal("60") + + +def test_unchecking_on_the_goal_screen_unlinks(auth_client, user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [expense.pk]}, + ) + + assert goal.linked_expenses.count() == 0 + + +def test_a_get_on_the_linking_screen_saves_nothing( + auth_client, user, account, category +): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + + auth_client.get( + reverse("goal_links", args=[goal.pk]), + {"shown": [expense.pk], "movements": [expense.pk]}, + ) + + assert goal.linked_expenses.count() == 0 + + +def test_saving_one_page_leaves_the_other_pages_alone( + auth_client, user, account, category +): + # La pantalla pagina de 10 en 10: un set() a secas con lo marcado en una + # página desvincularía todo lo de las demás. + expenses = [ + make_expense(user, account, category, on=date(2024, 5, day)) + for day in range(1, 15) + ] + goal = make_payment_goal(user, category=None) + goal.linked_expenses.set(expenses) + + first_page = list(auth_client.get(reverse("goal_links", args=[goal.pk])).context["page_obj"]) + # Se reenvía la primera página sin ninguna marca: solo esos se desvinculan. + auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [m.pk for m in first_page]}, + ) + + assert goal.linked_expenses.count() == len(expenses) - len(first_page) + + +# -------------------------------------------------------------------------- +# Aislamiento por usuario +# -------------------------------------------------------------------------- + + +def test_another_users_movement_cannot_be_linked_by_post( + auth_client, user, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + their_account = Account.objects.create( + owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True + ) + their_category = Category.objects.create(owner=intruder, name="Ajena") + theirs = make_expense(intruder, their_account, their_category, amount="500") + goal = make_payment_goal(user, category=None) + + response = auth_client.post( + reverse("goal_links", args=[goal.pk]), + {"shown": [theirs.pk], "movements": [theirs.pk]}, + ) + + assert response.status_code == 302 + assert goal.linked_expenses.count() == 0 + assert goal.progress == Decimal("0") + + +def test_another_users_goal_cannot_be_opened(auth_client, django_user_model): + intruder = django_user_model.objects.create_user(username="otro", password="x") + theirs = make_payment_goal(intruder, category=None) + + response = auth_client.get(reverse("goal_links", args=[theirs.pk])) + + assert response.status_code == 404 + + +def test_another_users_goal_cannot_be_chosen_from_the_expense_form( + auth_client, user, account, category, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + theirs = make_payment_goal(intruder, category=None) + + auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "75", + "category": category.pk, + "account": account.pk, + "description": "Compra", + "goals": [theirs.pk], + }, + ) + + assert theirs.linked_expenses.count() == 0 + + +# -------------------------------------------------------------------------- +# Borrado y rendimiento +# -------------------------------------------------------------------------- + + +def test_deleting_a_goal_keeps_the_linked_movements(user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + goal.delete() + + # Se limpia la tabla intermedia y el gasto sigue ahí. + expense.refresh_from_db() + assert Expense.objects.filter(pk=expense.pk).exists() + + +def test_deleting_a_linked_movement_leaves_the_goal_alone(user, account, category): + expense = make_expense(user, account, category) + goal = make_payment_goal(user, category=None) + goal.linked_expenses.add(expense) + + expense.delete() + goal.refresh_from_db() + + assert goal.linked_expenses.count() == 0 + assert goal.progress == Decimal("0") + + +def _queries_painting(auth_client, user, account, category, goals, links_each): + """Consultas que cuesta pintar goal_list con esos objetivos y vinculaciones.""" + Goal.objects.filter(owner=user).delete() + for _ in range(goals): + goal = make_payment_goal(user, category=None) + goal.linked_expenses.set( + [make_expense(user, account, category) for _ in range(links_each)] + ) + + with CaptureQueriesContext(connection) as captured: + assert auth_client.get(reverse("goal_list")).status_code == 200 + + return len(captured) + + +def test_linked_movements_do_not_cost_a_query_each( + auth_client, user, account, category +): + # El N+1 que reaparece con el M2M: una consulta por movimiento vinculado al + # pintar la lista. Triplicar las vinculaciones no debe mover el número. + few = _queries_painting(auth_client, user, account, category, 2, 1) + many = _queries_painting(auth_client, user, account, category, 2, 3) + + assert few == many + + +def test_each_extra_goal_costs_a_single_query(auth_client, user, account, category): + # Cada objetivo cuesta el agregado de su progress(), y nada más. + two = _queries_painting(auth_client, user, account, category, 2, 2) + four = _queries_painting(auth_client, user, account, category, 4, 2) + + assert four - two == 2 -- 2.45.2