From 57d44dad8ee97c7f210262850aa44d9f9288f7a9 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 19:58:22 +0200 Subject: [PATCH 01/17] Pasa chart_type al contexto del dashboard La vista calculaba chart_type ("day" o "month") pero nunca lo metia en el contexto, asi que la plantilla lo leia siempre vacio y el {% if chart_type == 'day' %} del titulo de la grafica no se cumplia nunca. El encabezado decia "Por Meses" tambien al mirar un mes suelto, cuando la serie que se dibuja debajo es por dias. Solo anade la clave que faltaba; el calculo ya estaba bien. Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 34d77ac..772ab13 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -605,6 +605,7 @@ def dashboard(request): "by_category_chart": by_category_chart, "chart_labels": chart_labels, "chart_data": chart_totals, + "chart_type": chart_type, "year_list": year_list, "months": list(range(1, 13)), "selected_year": selected_year, From b4c9f8507b9f3acb88bbc61c4ea460757012cbaa Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 19:58:31 +0200 Subject: [PATCH 02/17] Anade tests de regresion del dashboard La vista dashboard concentra los presets de periodo, el filtro por cuenta, la comparativa y las series de las graficas, y solo tenia cubierto el filtro por ano. Antes de tocarla conviene fijar por escrito lo que hace hoy, para que el refactor que viene no pueda cambiarlo sin que salte algo. Cubre los tres presets (this_month, last_month, this_year), el filtro por cuenta con su KPI de saldo, los valores por defecto de la comparativa cuando no esta activada, la diferencia y la tendencia cuando si lo esta, el chart_type segun se mire un mes o el ano entero, y el recorte a diez categorias de la grafica de distribucion. El test de la grafica de categorias comprueba tambien que by_category sigue trayendo las doce: el recorte es solo de la grafica, no del listado. Co-Authored-By: Claude Opus 5 --- .../expenses/tests/test_dashboard.py | 145 +++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py index 3e0d2b7..469db7f 100644 --- a/expenses_manager/expenses/tests/test_dashboard.py +++ b/expenses_manager/expenses/tests/test_dashboard.py @@ -2,7 +2,7 @@ import pytest from datetime import date from decimal import Decimal from django.urls import reverse -from expenses.models import Expense, Category +from expenses.models import Account, Expense, Category, Income pytestmark = pytest.mark.django_db @@ -80,3 +80,146 @@ def test_dashboard_filters_by_year(auth_client, user, account, category): assert len(chart_data) == 12 assert chart_data[4] == 20.0 # May = month 5 -> index 4 assert sum(chart_data) == 20.0 # only the 2024 expense contributes + + +def test_dashboard_preset_this_month(auth_client): + today = date.today() + + response = auth_client.get(reverse('dashboard'), {'period': 'this_month'}) + + assert response.context['selected_year'] == today.year + assert response.context['selected_month'] == today.month + + +def test_dashboard_preset_last_month(auth_client): + today = date.today() + expected_month = today.month - 1 or 12 + expected_year = today.year if today.month > 1 else today.year - 1 + + response = auth_client.get(reverse('dashboard'), {'period': 'last_month'}) + + assert response.context['selected_year'] == expected_year + assert response.context['selected_month'] == expected_month + + +def test_dashboard_preset_this_year_has_no_month(auth_client): + today = date.today() + + response = auth_client.get(reverse('dashboard'), {'period': 'this_year'}) + + assert response.context['selected_year'] == today.year + assert response.context['selected_month'] is None + + +def test_dashboard_filters_by_account(auth_client, user, account, category): + other = Account.objects.create( + owner=user, name="Otra", initial_balance=Decimal("100"), active=True + ) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("10"), date=date(2024, 3, 1), + ) + Expense.objects.create( + owner=user, account=other, category=category, + amount=Decimal("25"), date=date(2024, 3, 1), + ) + + response = auth_client.get( + reverse('dashboard'), + {'year': 2024, 'account': other.id}, + ) + + assert response.context['kpi_total'] == Decimal('25') + assert response.context['kpi_count'] == 1 + assert response.context['kpi_balance'] == Decimal('75') # 100 - 25 + + +def test_dashboard_without_compare_keeps_comparison_defaults(auth_client): + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['compare_enabled'] is False + assert response.context['kpi_previous_total'] == 0 + assert response.context['kpi_difference'] == 0 + assert response.context['kpi_percentage'] == 0 + assert response.context['kpi_trend'] == 'equal' + assert response.context['category_comparison'] == [] + + +def test_dashboard_compare_fills_difference_and_trend(auth_client, user, account, category): + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("20"), date=date(2024, 2, 15), + ) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("30"), date=date(2024, 3, 10), + ) + + response = auth_client.get( + reverse('dashboard'), + {'year': 2024, 'month': 3, 'compare': '1'}, + ) + + assert response.context['compare_enabled'] is True + assert response.context['kpi_previous_total'] == Decimal('20') + assert response.context['kpi_difference'] == Decimal('10') + assert response.context['kpi_trend'] == 'up' + assert float(response.context['kpi_percentage']) == 50.0 + + rows = {row['category']: row for row in response.context['category_comparison']} + assert rows[category.name]['current'] == 30.0 + assert rows[category.name]['previous'] == 20.0 + assert rows[category.name]['difference'] == 10.0 + + +def test_dashboard_chart_type_is_day_when_a_month_is_selected(auth_client): + response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) + + assert response.context['chart_type'] == 'day' + + +def test_dashboard_chart_type_is_month_for_the_whole_year(auth_client): + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['chart_type'] == 'month' + + +def test_dashboard_category_chart_keeps_only_top_ten(auth_client, user, account): + for i in range(12): + cat = Category.objects.create(name=f"Cat {i:02d}", owner=user) + Expense.objects.create( + owner=user, account=account, category=cat, + amount=Decimal(str(i + 1)), date=date(2024, 6, 1), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert len(response.context['by_category_chart']) == 10 + assert len(list(response.context['by_category'])) == 12 + + +def test_dashboard_kpi_balance_matches_sum_of_current_balances(auth_client, user, account, category): + savings = Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("500"), active=True + ) + Account.objects.create( + owner=user, name="Cerrada", initial_balance=Decimal("999"), active=False + ) + + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("40"), date=date(2024, 4, 1), + ) + Income.objects.create( + owner=user, account=savings, name="Nomina", + amount=Decimal("60"), date=date(2024, 4, 2), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + expected = sum( + acc.current_balance() + for acc in Account.objects.filter(owner=user, active=True) + ) + assert expected == Decimal('520') # (0 - 40) + (500 + 60), sin la inactiva + assert response.context['kpi_balance'] == expected From f074005a7f23e27424e02fcefffb748f3cf65bd2 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 20:12:03 +0200 Subject: [PATCH 03/17] Extrae helpers del dashboard y elimina el N+1 de saldos La vista pasaba de 226 lineas y mezclaba tres cosas distintas: traducir los parametros de la URL a un periodo, calcular los KPIs y montar las series de las graficas por cuenta. Saca las dos que son autonomas a _resolve_period y _build_account_charts y deja dashboard en 170. De paso quita el N+1: la vista llamaba a current_balance() una vez por cuenta para el KPI y otra vez por cuenta dentro del bucle de graficas, y cada llamada son dos queries. Ahora los saldos salen de _account_balances, que los calcula con dos agregados, y se reparten por un dict indexado por id. Medido con CaptureQueriesContext: con 1/3/6 cuentas se pasa de 25/53/95 queries a 21/37/61. accounts deja de ser un QuerySet y pasa a lista, porque ahora se recorre mas de una vez, y la cuenta seleccionada se busca en memoria en vez de con otra query. Lo que sigue costando una query por cuenta es monthly_balance(), que no se toca aqui. Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/views.py | 121 ++++++++++++++++++----------- 1 file changed, 76 insertions(+), 45 deletions(-) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 772ab13..4ad3976 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -112,6 +112,67 @@ def _account_balances(accounts): return account_balances, negative_accounts, total_balance +def _resolve_period(request, today): + """Traduce los parámetros de la URL en el periodo mirado por el dashboard. + + Devuelve (period, selected_year, selected_month), donde selected_month es + None cuando se está mirando el año completo.""" + period = request.GET.get("period", "") + + if period == "this_month": + return period, today.year, today.month + + if period == "last_month": + selected_year, selected_month = sub_months(today.year, today.month, 1) + return period, selected_year, selected_month + + if period == "this_year": + return period, today.year, None + + selected_year = _get_int(request.GET.get("year")) or today.year + selected_month = _get_int(request.GET.get("month")) or None + return period, selected_year, selected_month + + +def _build_account_charts(accounts, balance_by_account, selected_year, today): + """Serie mensual de saldo de cada cuenta para las gráficas del dashboard. + + En el año en curso la serie se corta en el mes actual y ese último punto + se sustituye por el saldo real de la cuenta, que llega ya calculado en + balance_by_account para no repetir current_balance() por cuenta.""" + accounts_charts = [] + + for acc in accounts: + try: + monthly_data = acc.monthly_balance(selected_year) + m_balance = [float(row["balance"]) for row in monthly_data] + except Exception: + logger.exception( + "Error calculando monthly_balance para la cuenta %s (año %s)", + acc.id, selected_year + ) + m_balance = [0] * 12 + + current_balance = balance_by_account[acc.id] + + if selected_year == today.year: + current_month_index = today.month - 1 + + if current_month_index < len(m_balance): + m_balance[current_month_index] = float(current_balance) + + m_balance = m_balance[:today.month] + + accounts_charts.append({ + "id": acc.id, + "name": acc.name, + "data": m_balance, + "current_balance": current_balance, + }) + + return accounts_charts + + @login_required def home(request): today = date.today() @@ -407,34 +468,27 @@ def expense_delete(request, pk): def dashboard(request): today = date.today() - period = request.GET.get("period", "") account_id = _get_int(request.GET.get("account")) compare_enabled = request.GET.get("compare") == "1" - + # Time presets - if period == "this_month": - selected_year, selected_month = today.year, today.month - elif period == "last_month": - selected_year, selected_month = sub_months(today.year, today.month, 1) - elif period == "this_year": - selected_year, selected_month = today.year, None - else: - selected_year = _get_int(request.GET.get("year")) or _get_int(today.year) - selected_month = _get_int(request.GET.get("month")) - selected_month = _get_int(selected_month) if selected_month else None - + period, selected_year, selected_month = _resolve_period(request, today) + # Accounts - accounts = Account.objects.filter(owner=request.user, active=True) + accounts = list(Account.objects.filter(owner=request.user, active=True)) selected_account_obj = None if account_id: - selected_account_obj = accounts.filter(id=account_id).first() - + selected_account_obj = next((a for a in accounts if a.id == account_id), None) + # Calculate the KPI + account_balances, _, total_balance = _account_balances(accounts) + balance_by_account = {row["account"].id: row["balance"] for row in account_balances} + if selected_account_obj: - kpi_balance = selected_account_obj.current_balance() + kpi_balance = balance_by_account[selected_account_obj.id] else: - kpi_balance = sum(account.current_balance() for account in accounts) - + kpi_balance = total_balance + # Filter by base expenses expenses = Expense.objects.filter(owner=request.user) if account_id: @@ -561,33 +615,10 @@ def dashboard(request): }) # Anual evolution by accounts - accounts_charts = [] - for acc in accounts: - try: - monthly_data = acc.monthly_balance(selected_year) - m_balance = [float(row["balance"]) for row in monthly_data] - except Exception: - logger.exception( - "Error calculando monthly_balance para la cuenta %s (año %s)", - acc.id, selected_year - ) - m_balance = [0] * 12 - - if selected_year == today.year: - current_month_index = today.month - 1 - - if current_month_index < len(m_balance): - m_balance[current_month_index] = float(acc.current_balance()) + accounts_charts = _build_account_charts( + accounts, balance_by_account, selected_year, today + ) - m_balance = m_balance[:today.month] - - accounts_charts.append({ - "id": acc.id, - "name": acc.name, - "data": m_balance, - "current_balance": acc.current_balance(), - }) - # Goals goals = Goal.objects.filter(owner=request.user) From 1cc13bae57f2ec1af23a41be676a1ff0a017f08f Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 20:12:38 +0200 Subject: [PATCH 04/17] Borra JS muerto y la seccion comparativa duplicada del dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El bloque - @@ -393,23 +365,6 @@ - {% if compare_enabled %} -
-

Comparativa

- -

- Diferencia: - - {% if kpi_trend == "up" %}+{% endif %} - {{ kpi_difference_abs|floatformat:2 }} € - - {% if kpi_percentage %} - ({{ kpi_percentage|floatformat:1 }}%) - {% endif %} -

-
- {% endif %} - From beeda95d5d71ce3fcbeeabda5a4efbda4f8d8f97 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 20:13:03 +0200 Subject: [PATCH 05/17] Corrige la jerarquia de encabezados del dashboard El dashboard empezaba en

y bajaba hasta

, sin ningun

. La pagina no tenia titulo de documento y las secciones colgaban de un nivel que no existia, asi que un lector de pantalla no podia recorrerla por encabezados. Sube cada nivel uno: el nombre de la cuenta (o "Todas las cuentas") pasa a

, las secciones a

y el nombre de cada cuenta dentro de su tarjeta de grafica a

. El selector .dashboard-context h2 pasa a h1 para seguir al elemento. Solo cambian los niveles. Como base.css no definia tamanos de encabezado, el aspecto lo daban los valores por defecto del navegador y esto si mueve los tamanos; el commit de la escala tipografica los fija. Co-Authored-By: Claude Opus 5 --- .../expenses/static/expenses/css/base.css | 2 +- .../templates/expenses/dashboard.html | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index e827586..0737050 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -273,7 +273,7 @@ a.danger:visited, margin-bottom: 1rem; } -.dashboard-context h2 { +.dashboard-context h1 { margin: 0; } diff --git a/expenses_manager/expenses/templates/expenses/dashboard.html b/expenses_manager/expenses/templates/expenses/dashboard.html index bf14c4f..b0ef7c4 100644 --- a/expenses_manager/expenses/templates/expenses/dashboard.html +++ b/expenses_manager/expenses/templates/expenses/dashboard.html @@ -7,13 +7,13 @@ {% block content %}
-

+

{% if selected_account_obj %} {{ selected_account_obj.name }} {% else %} Todas las cuentas {% endif %} -

+

{% if selected_month %} @@ -137,7 +137,7 @@ {% if compare_enabled %}

-

Resumen comparativo

+

Resumen comparativo

Gastos periodo actual: {{ kpi_total|floatformat:2 }} €
Gastos periodo anterior: {{ kpi_previous_total|floatformat:2 }} €
@@ -150,7 +150,7 @@

-

Desglose de cambios por categoría

+

Desglose de cambios por categoría

@@ -184,21 +184,21 @@
-

Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})

+

Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})

-

Distribución por Categorías

+

Distribución por Categorías

-

Gastos Recientes

+

Gastos Recientes

@@ -262,11 +262,11 @@ }); -

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

+

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

{% for acc in accounts_charts %}
-

{{ acc.name }}

+

{{ acc.name }}

Saldo actual: {{ acc.current_balance|floatformat:2 }}€

@@ -369,7 +369,7 @@ -

Objetivos

+

Objetivos

{% if goals %}
{% for goal in goals %} From 3d47f8bd906f06d6ad701a330f176fd42d47e01b Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 20:13:28 +0200 Subject: [PATCH 06/17] Usa un unico h1 por pagina en el resto de vistas Cuatro paginas tenian su titulo en

sin ningun

encima: el formulario de categoria, ajustes, el login y la ayuda de contrasena. Y el listado de categorias si tenia

, pero colgaba de el dos

, saltandose el nivel intermedio. Sube los titulos de pagina a

y los subtitulos al nivel que les toca: las dos secciones del listado de categorias a

y las tres tarjetas de ajustes a

. El selector .settings-card h3 pasa a h2 para seguirlas y mantener su margin: 0 0 0.25rem, que es lo que evita que el titulo abra hueco dentro de la tarjeta. En login y ayuda de contrasena el

ademas importa para el layout: .auth-container tiene padding: 2rem, y el padding impide que colapse el margen superior del primer hijo. Con la escala que viene despues, un

ahi sumaria su margen a ese padding y dejaria el titulo a 56px del borde; el

no lleva margen superior. Verificado renderizando las 36 vistas: todas con un solo h1 y sin saltos de nivel. Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/static/expenses/css/base.css | 2 +- expenses_manager/expenses/templates/categories/form.html | 2 +- expenses_manager/expenses/templates/categories/list.html | 4 ++-- .../expenses/templates/registration/login.html | 2 +- .../expenses/templates/registration/password_help.html | 2 +- expenses_manager/expenses/templates/settings/index.html | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 0737050..f1c3294 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -898,7 +898,7 @@ tr:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } -.settings-card h3 { +.settings-card h2 { margin: 0 0 0.25rem 0; } diff --git a/expenses_manager/expenses/templates/categories/form.html b/expenses_manager/expenses/templates/categories/form.html index 581fff7..7feeb97 100644 --- a/expenses_manager/expenses/templates/categories/form.html +++ b/expenses_manager/expenses/templates/categories/form.html @@ -5,7 +5,7 @@ {% endblock %} {% block content %} -

Editar categoría

+

Editar categoría

{% csrf_token %} diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index 224af55..ca0491d 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -8,7 +8,7 @@

Mis categorías

-

Nueva categoría

+

Nueva categoría

{% csrf_token %} {% include "expenses/_form_fields.html" %} @@ -17,7 +17,7 @@
-

Listado

+

Listado

diff --git a/expenses_manager/expenses/templates/registration/login.html b/expenses_manager/expenses/templates/registration/login.html index e9df499..068d46c 100644 --- a/expenses_manager/expenses/templates/registration/login.html +++ b/expenses_manager/expenses/templates/registration/login.html @@ -2,7 +2,7 @@ {% block content %} -

Iniciar sesión

+

Iniciar sesión

{% csrf_token %} diff --git a/expenses_manager/expenses/templates/registration/password_help.html b/expenses_manager/expenses/templates/registration/password_help.html index 1ff3636..ad0dcc9 100644 --- a/expenses_manager/expenses/templates/registration/password_help.html +++ b/expenses_manager/expenses/templates/registration/password_help.html @@ -2,7 +2,7 @@ {% block content %} -

Recuperar contraseña

+

Recuperar contraseña

Esta aplicación actualmente no envía correos de recuperación automáticos. diff --git a/expenses_manager/expenses/templates/settings/index.html b/expenses_manager/expenses/templates/settings/index.html index 7226251..9b9e81e 100644 --- a/expenses_manager/expenses/templates/settings/index.html +++ b/expenses_manager/expenses/templates/settings/index.html @@ -5,23 +5,23 @@ {% endblock %} {% block content %} -

Configuración

+

Configuración

Gestiona las categorías, etiquetas y objetivos de tu cuenta

From b4508a13db8531e78aa4d20cf9a1670b07266c7e Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Mon, 14 Sep 2026 20:13:49 +0200 Subject: [PATCH 07/17] Anade una escala tipografica para los encabezados base.css nunca ha definido tamanos de encabezado, asi que los ponia el navegador: 32px el h1, 24px el h2 y 18,7px el h3. En una vista densa de datos como el dashboard eso compite con las propias cifras, y ademas dejaba los tamanos a merced de que un encabezado cambiara de nivel. Fija 1.625rem / 1.25rem / 1rem con font-weight 600. Los margenes van en rem a proposito: los del navegador van en em, escalan con el tamano de fuente y dejan huecos distintos entre secciones que deberian separarse igual. Va en la zona de estilos base, antes de los componentes, para que las reglas que ya ajustan encabezados concretos (.dashboard-context h1, .settings-card h2, .home-section > h2, .info-panel-content h2) sigan ganando por orden. Afecta a toda la aplicacion. Lo que mas se nota es el titulo de pagina: pierde 21px de aire por arriba, porque el margen superior del h1 pasa a 0 y el padding de .content impedia que colapsara. Entre secciones de la home no cambia nada, ahi manda el margin-bottom de 2.5rem de .home-section. Ningun media query toca tamanos de fuente, asi que en movil la escala es la misma, y el bloque no declara colores, asi que el tema oscuro no se entera. Co-Authored-By: Claude Opus 5 --- .../expenses/static/expenses/css/base.css | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index f1c3294..beed3c2 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -97,6 +97,31 @@ a.danger:visited, color: var(--color-danger-accent); } +/* Escala tipográfica. Los márgenes van en rem a propósito: los del navegador + van en em y escalan con el tamaño de fuente, dejando huecos irregulares + entre secciones. */ + +h1 { + font-size: 1.625rem; + font-weight: 600; + line-height: 1.2; + margin: 0 0 1rem; +} + +h2 { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.25; + margin: 1.5rem 0 0.75rem; +} + +h3 { + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + margin: 1rem 0 0.5rem; +} + /* ========================= */ /* Topbar / navigation */ /* ========================= */ From 8c0225a2979522a3017029e9cad41a87e264873d Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:30:01 +0200 Subject: [PATCH 08/17] Anade el campo is_transfer a gastos e ingresos Un traspaso entre cuentas propias se registra hoy como dos movimientos: un gasto en la cuenta de origen y un ingreso en la de destino. Los saldos cuadran, pero el gasto infla los analisis (KPIs, graficos, objetivos) con dinero que nunca ha salido del patrimonio del usuario. Marcar el movimiento hace falta en los dos modelos, no en uno solo. Una "categoria de traspaso" cubriria unicamente la mitad de la operacion, porque Income no tiene categoria; de ahi el booleano en ambos. El campo solo se anade aqui: ninguna consulta lo usa todavia, asi que este commit no cambia ningun numero de la aplicacion. Co-Authored-By: Claude Opus 5 --- ..._expense_is_transfer_income_is_transfer.py | 23 +++++++++++++++++++ expenses_manager/expenses/models.py | 21 +++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 expenses_manager/expenses/migrations/0013_expense_is_transfer_income_is_transfer.py diff --git a/expenses_manager/expenses/migrations/0013_expense_is_transfer_income_is_transfer.py b/expenses_manager/expenses/migrations/0013_expense_is_transfer_income_is_transfer.py new file mode 100644 index 0000000..1f6fd3d --- /dev/null +++ b/expenses_manager/expenses/migrations/0013_expense_is_transfer_income_is_transfer.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.10 on 2026-09-15 10:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0012_alter_account_options_alter_category_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='is_transfer', + field=models.BooleanField(default=False, help_text='Márcalo si este movimiento solo mueve dinero entre cuentas propias. No contará como gasto ni como ingreso en los análisis, pero sí afectará al saldo de la cuenta.', verbose_name='Es un traspaso entre cuentas'), + ), + migrations.AddField( + model_name='income', + name='is_transfer', + field=models.BooleanField(default=False, help_text='Márcalo si este movimiento solo mueve dinero entre cuentas propias. No contará como gasto ni como ingreso en los análisis, pero sí afectará al saldo de la cuenta.', verbose_name='Es un traspaso entre cuentas'), + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index e6dc33b..14ca8d8 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -228,6 +228,16 @@ class Expense(models.Model): related_name="expenses", ) + is_transfer = models.BooleanField( + default=False, + verbose_name="Es un traspaso entre cuentas", + help_text=( + "Márcalo si este movimiento solo mueve dinero entre cuentas propias. " + "No contará como gasto ni como ingreso en los análisis, pero sí " + "afectará al saldo de la cuenta." + ), + ) + created_at = models.DateField(auto_now_add=True) class Meta: @@ -246,6 +256,17 @@ class Income(models.Model): name = models.CharField(max_length=150) amount = models.DecimalField(max_digits=12, decimal_places=2) date = models.DateField() + + is_transfer = models.BooleanField( + default=False, + verbose_name="Es un traspaso entre cuentas", + help_text=( + "Márcalo si este movimiento solo mueve dinero entre cuentas propias. " + "No contará como gasto ni como ingreso en los análisis, pero sí " + "afectará al saldo de la cuenta." + ), + ) + created_at = models.DateTimeField(auto_now_add=True) class Meta: From e2b756bcc78191fcf67122f55e52126fd71c10f7 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:30:43 +0200 Subject: [PATCH 09/17] Excluye los traspasos de los calculos de analisis Un traspaso mueve dinero entre cuentas propias: suma al saldo de cada cuenta pero no es gasto ni ingreso real. Las agregaciones de analisis (KPIs, graficos de home y dashboard, comparativa de periodos, totales del listado y objetivos de pago y presupuesto) pasan a excluirlo. Hay dos comportamientos opuestos sobre el mismo campo y conviene no confundirlos: - Analisis: excluye los traspasos. De ahi el helper _analysis_expenses, que documenta en su docstring donde NO debe usarse. - Saldo: los incluye. _account_balances, current_balance, monthly_balance, balance_until y monthly_net quedan intactos a proposito; filtrarlos ahi descuadraria todos los saldos de la aplicacion de forma silenciosa, que es mucho peor y menos evidente que inflar un KPI. Por el mismo motivo la rama saving de Goal.progress tampoco filtra: mandar dinero a la cuenta de ahorro es justo como se progresa en ese objetivo. Los ultimos movimientos de home dejan de colgar de la consulta de KPIs y usan la suya propia: son un listado, no un analisis, y un traspaso tiene que poder verse ahi. Lo mismo en el listado de gastos, donde la tabla los sigue mostrando y solo los totales los descuentan. Los managers por defecto de Expense e Income no se tocan. Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/models.py | 7 ++- .../templates/expenses/expense_list.html | 2 +- expenses_manager/expenses/views.py | 51 +++++++++++++++---- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 14ca8d8..97e0e19 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -391,6 +391,9 @@ class Goal(models.Model): if self.kind == self.KIND_SAVING: # Provisional: saldo de la cuenta asociada. Cuando exista el # módulo de inversiones, esta rama es lo único que hay que tocar. + # Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de + # ahorro es precisamente cómo se progresa en un objetivo de ahorro, + # así que no filtres is_transfer en esta rama. return self.account.current_balance() if self.account else Decimal("0") if not self.category: @@ -401,9 +404,11 @@ class Goal(models.Model): else: category_ids = [self.category_id] + # 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) start = self._period_start() if start: diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html index 11b6134..10db769 100644 --- a/expenses_manager/expenses/templates/expenses/expense_list.html +++ b/expenses_manager/expenses/templates/expenses/expense_list.html @@ -96,7 +96,7 @@
- Total: {{ kpi_total|floatformat:2 }}€ | + Total (sin traspasos): {{ kpi_total|floatformat:2 }}€ | Gastos: {{ kpi_count }} | Categorías: {{ kpi_categories }}
diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 4ad3976..c9864c1 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -77,9 +77,26 @@ def _category_tree(categories): return rows +def _analysis_expenses(user): + """Gastos para análisis: excluye los traspasos entre cuentas propias. + + Un traspaso mueve dinero entre cuentas del usuario, así que suma al saldo de + cada cuenta pero NO es gasto real. Usa este helper en KPIs, gráficos y + comparativas. + + NO lo uses en cálculos de saldo (_account_balances, current_balance, + monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan, + y filtrarlos descuadraría todos los saldos. + """ + return Expense.objects.filter(owner=user).exclude(is_transfer=True) + + def _account_balances(accounts): """Calcula el saldo de cada cuenta con dos queries agregadas (nada de - account.current_balance() en bucle, que dispararía N+1).""" + account.current_balance() en bucle, que dispararía N+1). + + Los traspasos cuentan aquí a propósito: son movimientos reales de las + cuentas. No metas _analysis_expenses en esta función.""" account_ids = [a.id for a in accounts] expense_totals = { @@ -176,7 +193,9 @@ def _build_account_charts(accounts, balance_by_account, selected_year, today): @login_required def home(request): today = date.today() - expenses = Expense.objects.filter(owner=request.user) + # Base de los KPIs, la comparativa y el mini-gráfico: sin traspasos. + # Los últimos movimientos usan su propia query, que sí los incluye. + expenses = _analysis_expenses(request.user) # ---- KPIs del mes en curso ---- month_expenses = expenses.filter(date__year=today.year, date__month=today.month) @@ -214,9 +233,14 @@ def home(request): has_alerts = bool(exceeded_goals or negative_accounts) # ---- Últimos movimientos (gastos + ingresos mezclados) ---- - recent_expenses = expenses.select_related("category", "account").order_by( - "-date", "-id" - )[:8] + # Es un listado, no un análisis: los traspasos son movimientos reales de las + # cuentas y salen marcados con un distintivo, así que no se cuelga de + # "expenses" (que ya los ha filtrado) sino de la query sin filtrar. + recent_expenses = ( + Expense.objects.filter(owner=request.user) + .select_related("category", "account") + .order_by("-date", "-id")[:8] + ) recent_incomes = ( Income.objects.filter(owner=request.user) .select_related("account") @@ -329,11 +353,16 @@ def expense_list(request): expenses = expenses.order_by("-date") - total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0 + # La tabla sigue listando los traspasos (hay que poder verlos y corregirlos), + # pero el total y los contadores miden gasto real, así que los excluyen. Por + # eso la etiqueta del total dice "sin traspasos" en la plantilla. + analysis_expenses = expenses.exclude(is_transfer=True) - expense_count = expenses.count() + total_amount = analysis_expenses.aggregate(total=Sum("amount"))["total"] or 0 - category_count = expenses.values("category").distinct().count() + expense_count = analysis_expenses.count() + + category_count = analysis_expenses.values("category").distinct().count() # Pagination paginator = Paginator(expenses, 10) @@ -490,7 +519,9 @@ def dashboard(request): kpi_balance = total_balance # Filter by base expenses - expenses = Expense.objects.filter(owner=request.user) + # Todo lo que cuelga de aquí es análisis (KPIs, gráficos, comparativa), así + # que va sin traspasos. Los saldos de arriba sí los incluyen. + expenses = _analysis_expenses(request.user) if account_id: expenses = expenses.filter(account_id=account_id) @@ -576,7 +607,7 @@ def dashboard(request): category_comparison = [] if compare_enabled: - previous_expenses = Expense.objects.filter(owner=request.user) + previous_expenses = _analysis_expenses(request.user) if account_id: previous_expenses = previous_expenses.filter(account_id=account_id) From 2d7cd8cb685bc7588afc7aff865745f51519dc2b Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:31:11 +0200 Subject: [PATCH 10/17] Permite marcar un traspaso y distinguirlo en la interfaz Sin esto el campo is_transfer no es alcanzable: no aparece en los formularios de gasto ni de ingreso, asi que nadie puede marcar un movimiento como traspaso. Ademas, un movimiento que no suma al total tiene que verse distinto o el usuario no entendera por que sus numeros no cuadran con la tabla. Se anade un distintivo "Traspaso" en el listado de gastos, en los ultimos movimientos de home y en los gastos recientes del dashboard, que son los tres sitios donde se listan movimientos sin agregarlos. El distintivo usa una variante nueva, .badge-neutral, en vez de reutilizar .badge-inactive: un traspaso es informacion, no un estado degradado, y pintarlo con el color de error o de cuenta inactiva daria a entender que algo va mal. Las variables que usa (--color-chip-bg y --color-text-muted) ya existen en los dos temas. Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/forms.py | 3 ++- expenses_manager/expenses/static/expenses/css/base.css | 7 +++++++ .../expenses/templates/expenses/dashboard.html | 7 ++++++- .../expenses/templates/expenses/expense_list.html | 7 ++++++- expenses_manager/expenses/templates/expenses/home.html | 7 ++++++- expenses_manager/expenses/views.py | 2 ++ 6 files changed, 29 insertions(+), 4 deletions(-) diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index 83ee34e..e2fed43 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -27,6 +27,7 @@ class ExpenseForm(forms.ModelForm): "category", "account", "tags", + "is_transfer", ] widgets = { "date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}), @@ -50,7 +51,7 @@ class AccountForm(forms.ModelForm): class IncomeForm(forms.ModelForm): class Meta: model = Income - fields = ["account", "name", "amount", "date"] + fields = ["account", "name", "amount", "date", "is_transfer"] widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})} def __init__(self, *args, **kwargs): diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index beed3c2..5031c8c 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -732,6 +732,13 @@ tr:hover { color: var(--color-danger-text); } +/* Etiqueta informativa, sin carga de error ni de éxito: marca un movimiento + como lo que es, no como un estado degradado. */ +.badge-neutral { + background-color: var(--color-chip-bg); + color: var(--color-text-muted); +} + .row-inactive { opacity: 0.55; } diff --git a/expenses_manager/expenses/templates/expenses/dashboard.html b/expenses_manager/expenses/templates/expenses/dashboard.html index b0ef7c4..5bfb08e 100644 --- a/expenses_manager/expenses/templates/expenses/dashboard.html +++ b/expenses_manager/expenses/templates/expenses/dashboard.html @@ -215,7 +215,12 @@
- + diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html index 10db769..d31d670 100644 --- a/expenses_manager/expenses/templates/expenses/expense_list.html +++ b/expenses_manager/expenses/templates/expenses/expense_list.html @@ -117,7 +117,12 @@ {% for expense in page_obj %} - + {% endfor %} diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index ef29c7b..3dd4a08 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -445,6 +445,10 @@ def expense_list(request): advanced_filters_open = bool(category or selected_tags) + filters_active = bool( + year or month or category or selected_tags or account_id or range_active + ) + return render( request, "expenses/expense_list.html", @@ -470,6 +474,7 @@ def expense_list(request): "accounts": Account.objects.filter(owner=request.user), "selected_account": account_id, "advanced_filters_open": advanced_filters_open, + "filters_active": filters_active, "query_params": query_params.urlencode(), }, )
{{ exp.date|date:"d/m/Y" }} {{ exp.account.name }}{{ exp.category.name }} + {{ exp.category.name }} + {% if exp.is_transfer %} + Traspaso + {% endif %} + {{ exp.description|default:"-" }} {{ exp.amount|floatformat:2 }}
{{ expense.date }}{{ expense.category.name }} + {{ expense.category.name }} + {% if expense.is_transfer %} + Traspaso + {% endif %} + {{ expense.amount }} {{ expense.account.name }} diff --git a/expenses_manager/expenses/templates/expenses/home.html b/expenses_manager/expenses/templates/expenses/home.html index 06ff6ca..c5a9efc 100644 --- a/expenses_manager/expenses/templates/expenses/home.html +++ b/expenses_manager/expenses/templates/expenses/home.html @@ -96,7 +96,12 @@ {% for mov in movements %}
  • {{ mov.date }} - {{ mov.label }} + + {{ mov.label }} + {% if mov.is_transfer %} + Traspaso + {% endif %} + {% if mov.kind == 'income' %}+{% else %}−{% endif %}{{ mov.amount|floatformat:2 }}€ diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index c9864c1..7149529 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -255,6 +255,7 @@ def home(request): "label": e.category.name, "account": e.account.name, "amount": e.amount, + "is_transfer": e.is_transfer, } for e in recent_expenses ] @@ -265,6 +266,7 @@ def home(request): "label": i.name, "account": i.account.name, "amount": i.amount, + "is_transfer": i.is_transfer, } for i in recent_incomes ], From 2c05318e0bd7325618b89360df3e827c253a916f Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:31:23 +0200 Subject: [PATCH 11/17] Anade tests de los traspasos El campo is_transfer tiene dos comportamientos opuestos y uno de ellos falla en silencio: si el filtro se cuela en un calculo de saldo, los numeros siguen apareciendo, solo que mal. Los tests se organizan en dos bloques que cubren justo esa frontera. - Analisis: el traspaso no cuenta en los KPIs de home ni del dashboard, ni en el total del listado, ni en la comparativa de periodos, ni en el progreso de los objetivos de pago y presupuesto. - Saldo: el traspaso SI cuenta en current_balance, monthly_balance, balance_until, monthly_net y en el objetivo de ahorro. Tambien se cubre que el listado de gastos siga mostrando el traspaso aunque su total lo descuente, que es la parte que un filtro puesto de mas romperia. Co-Authored-By: Claude Opus 5 --- .../expenses/tests/test_transfers.py | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 expenses_manager/expenses/tests/test_transfers.py diff --git a/expenses_manager/expenses/tests/test_transfers.py b/expenses_manager/expenses/tests/test_transfers.py new file mode 100644 index 0000000..33f42c3 --- /dev/null +++ b/expenses_manager/expenses/tests/test_transfers.py @@ -0,0 +1,242 @@ +"""Traspasos entre cuentas (is_transfer). + +Un traspaso no es gasto ni ingreso real, pero sí mueve dinero entre cuentas. +De ahí los dos bloques de este fichero, que comprueban lo contrario el uno del +otro: los análisis excluyen los traspasos, los saldos los incluyen. +""" +import pytest +from datetime import date +from decimal import Decimal +from django.urls import reverse +from expenses.models import Account, Category, Expense, Goal, Income + +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_expense(user, account, category, amount, on, is_transfer=False): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + +def make_income(user, account, amount, on, name="Ingreso", is_transfer=False): + return Income.objects.create( + owner=user, + account=account, + name=name, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + +# -------------------------------------------------------------------------- +# Análisis: los traspasos NO cuentan +# -------------------------------------------------------------------------- + +def test_dashboard_kpi_total_ignores_transfer(auth_client, user, account, category): + make_expense(user, account, category, "100", date(2024, 5, 10)) + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + + +def test_dashboard_by_category_ignores_transfer(auth_client, user, account, category): + other = Category.objects.create(owner=user, name="Traspasos") + make_expense(user, account, category, "100", date(2024, 5, 10)) + make_expense(user, account, other, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + rows = {row["category__name"]: row["total"] for row in response.context["by_category"]} + assert rows == {category.name: Decimal("100")} + + +def test_dashboard_comparison_ignores_transfer(auth_client, user, account, category): + make_expense(user, account, category, "30", date(2024, 3, 10)) + make_expense(user, account, category, "20", date(2024, 2, 15)) + make_expense(user, account, category, "500", date(2024, 2, 16), is_transfer=True) + + response = auth_client.get( + reverse("dashboard"), {"year": 2024, "month": 3, "compare": "1"} + ) + + assert response.context["kpi_previous_total"] == Decimal("20") + assert response.context["kpi_difference"] == Decimal("10") + + +def test_home_kpi_total_ignores_transfer(auth_client, user, account, category): + today = date.today() + make_expense(user, account, category, "100", today) + make_expense(user, account, category, "400", today, is_transfer=True) + + response = auth_client.get(reverse("home")) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + + +def test_home_previous_month_comparison_ignores_transfer( + auth_client, user, account, category +): + today = date.today() + # Día 1 para que reste un mes sin caer en un día inexistente. + first = date(today.year, today.month, 1) + prev = date(first.year - 1, 12, 1) if first.month == 1 else date( + first.year, first.month - 1, 1 + ) + + make_expense(user, account, category, "10", prev) + make_expense(user, account, category, "900", prev, is_transfer=True) + + response = auth_client.get(reverse("home")) + + assert response.context["prev_total"] == Decimal("10") + + +def test_expense_list_total_ignores_transfer_but_row_is_listed( + auth_client, user, account, category +): + make_expense(user, account, category, "100", date(2024, 5, 10)) + transfer = make_expense( + user, account, category, "400", date(2024, 5, 11), is_transfer=True + ) + + response = auth_client.get(reverse("expense_list"), {"year": 2024}) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + # El traspaso sigue en la tabla aunque no sume en el total. + assert transfer in list(response.context["page_obj"]) + + +def test_budget_goal_progress_ignores_transfer(user, account, category): + today = date.today() + make_expense(user, account, category, "50", today) + make_expense(user, account, category, "300", today, is_transfer=True) + + goal = Goal.objects.create( + owner=user, + name="Presupuesto mensual", + target_amount=Decimal("200"), + kind=Goal.KIND_BUDGET, + category=category, + period=Goal.PERIOD_MONTH, + ) + + assert goal.progress == Decimal("50") + assert not goal.is_exceeded() + + +# -------------------------------------------------------------------------- +# Saldos: los traspasos SÍ cuentan +# -------------------------------------------------------------------------- + +def test_transfer_expense_still_lowers_source_balance(user, account, category): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + assert account.current_balance() == Decimal("600") + + +def test_transfer_income_still_raises_target_balance(user, savings): + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + assert savings.current_balance() == Decimal("400") + + +def test_transfer_keeps_total_balance_unchanged(auth_client, user, account, category, savings): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("home")) + + # El dinero cambia de sitio, el patrimonio no se mueve. + assert response.context["total_balance"] == Decimal("1000") + + +def test_saving_goal_progress_counts_the_transfer(user, savings): + """El punto más delicado: un traspaso a la cuenta de ahorro ES el progreso.""" + make_income(user, savings, "400", date(2024, 5, 11), is_transfer=True) + + goal = Goal.objects.create( + owner=user, + name="Fondo de emergencia", + target_amount=Decimal("1000"), + kind=Goal.KIND_SAVING, + account=savings, + ) + + assert goal.progress == Decimal("400") + + +def test_account_list_balance_counts_the_transfer(auth_client, user, account, category): + account.initial_balance = Decimal("1000") + account.save() + make_expense(user, account, category, "400", date(2024, 5, 11), is_transfer=True) + + response = auth_client.get(reverse("account_list")) + + rows = {row["account"].id: row["balance"] for row in response.context["account_rows"]} + assert rows[account.id] == Decimal("600") + + +# -------------------------------------------------------------------------- +# Formularios +# -------------------------------------------------------------------------- + +def test_expense_form_can_mark_a_transfer(auth_client, user, account, category): + response = auth_client.post( + reverse("expense_create"), + { + "date": "2024-05-11", + "amount": "400", + "description": "", + "category": category.id, + "account": account.id, + "is_transfer": "on", + }, + ) + + assert response.status_code == 302 + assert Expense.objects.get(owner=user).is_transfer is True + + +def test_income_form_can_mark_a_transfer(auth_client, user, account): + response = auth_client.post( + reverse("income_create"), + { + "account": account.id, + "name": "Traspaso desde nómina", + "amount": "400", + "date": "2024-05-11", + "is_transfer": "on", + }, + ) + + assert response.status_code == 302 + assert Income.objects.get(owner=user).is_transfer is True + + +def test_expense_defaults_to_not_a_transfer(user, account, category): + expense = make_expense(user, account, category, "10", date(2024, 5, 11)) + + assert expense.is_transfer is False From bb5ade4cc9554c40d3206d4c0e66ed5243c770c0 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:31:31 +0200 Subject: [PATCH 12/17] Quita un div de cierre sobrante en el listado de repostajes La plantilla cerraba un div que nunca se habia abierto, asi que el del .table-wrap acababa cerrando el contenedor de la pagina y todo lo que viniera despues quedaba fuera de su sitio. Ahora las aperturas y los cierres cuadran. Co-Authored-By: Claude Opus 5 --- expenses_manager/expenses/templates/fuel/list.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/expenses_manager/expenses/templates/fuel/list.html b/expenses_manager/expenses/templates/fuel/list.html index 2cfdb82..1181699 100644 --- a/expenses_manager/expenses/templates/fuel/list.html +++ b/expenses_manager/expenses/templates/fuel/list.html @@ -10,8 +10,6 @@ ➕ Nuevo repostaje - -
    From c395c638e9523910af2c5060a0afea56235a18b1 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:32:11 +0200 Subject: [PATCH 13/17] Anade un rango de fechas libre al listado de gastos Los selectores de ano y mes solo saben expresar periodos naturales completos. "Del 15 de marzo al 3 de abril" o "desde que empece el trabajo nuevo" no se pueden pedir, y son justo las preguntas que uno le hace a un gestor de gastos. Se anaden dos parametros date_from y date_to, opcionales por separado: "desde el 1 de marzo" sin fecha final es un filtro perfectamente valido. Si el rango viene invertido se ignoran las dos fechas, porque devolver una lista vacia sin explicacion es peor que descartar un filtro mal puesto. Cuando hay rango, manda el rango y los selectores de ano y mes no se aplican: combinar ambos criterios da intersecciones que el usuario no ha pedido y no puede ver. La plantilla lo avisa con un mensaje y un enlace para quitarlo, en vez de dejar los selectores mostrando un periodo que no es el que se esta viendo. Los campos de fecha comparten estilo con el resto de filtros, incluido el anillo de foco, para que no desentonen con los selectores de al lado. Co-Authored-By: Claude Opus 5 --- .../expenses/static/expenses/css/base.css | 12 ++++- .../templates/expenses/_date_range.html | 12 +++++ .../templates/expenses/expense_list.html | 10 ++++ expenses_manager/expenses/views.py | 52 +++++++++++++++++-- 4 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 expenses_manager/expenses/templates/expenses/_date_range.html diff --git a/expenses_manager/expenses/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index 5031c8c..6593c5a 100644 --- a/expenses_manager/expenses/static/expenses/css/base.css +++ b/expenses_manager/expenses/static/expenses/css/base.css @@ -396,7 +396,8 @@ button[type="submit"]:hover, gap: 0.5rem; } -.filters-main select { +.filters-main select, +.filters-main input[type="date"] { padding: 0.5rem 0.7rem; font-family: inherit; font-size: 0.95rem; @@ -406,11 +407,18 @@ button[type="submit"]:hover, border-radius: 6px; } -.filters-main select:focus { +.filters-main select:focus, +.filters-main input[type="date"]:focus { outline: 2px solid var(--color-focus-ring); outline-offset: 1px; } +.range-notice { + margin-top: 0.5rem; + font-size: 0.9rem; + color: var(--color-text-muted); +} + .filters-advanced { margin-top: 0.5rem; } diff --git a/expenses_manager/expenses/templates/expenses/_date_range.html b/expenses_manager/expenses/templates/expenses/_date_range.html new file mode 100644 index 0000000..16c6f82 --- /dev/null +++ b/expenses_manager/expenses/templates/expenses/_date_range.html @@ -0,0 +1,12 @@ +{% comment %} +Campos del rango de fechas libre. Se incluye dentro de un . +Espera date_from y date_to en el contexto. +{% endcomment %} + + + + + + diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html index d31d670..b484fa0 100644 --- a/expenses_manager/expenses/templates/expenses/expense_list.html +++ b/expenses_manager/expenses/templates/expenses/expense_list.html @@ -47,10 +47,20 @@ {% endfor %} + {% include "expenses/_date_range.html" %} + Limpiar + {% if range_active %} +

    + Mostrando el rango de fechas seleccionado; los filtros de año y mes no + se aplican. + Quitar el rango +

    + {% endif %} +
    hasta) se ignoran LAS DOS fechas: devolver + una lista vacía sin explicación es peor que ignorar un filtro mal puesto. + """ + date_from = _get_date(request.GET.get("date_from")) + date_to = _get_date(request.GET.get("date_to")) + + if date_from and date_to and date_from > date_to: + return None, None + + return date_from, date_to + + def _safe_next(request): next_url = request.POST.get("next") or request.GET.get("next") @@ -336,11 +367,21 @@ def expense_list(request): if (t_id:= _get_int(t)) is not None ] - if year: - expenses = expenses.filter(date__year=year) + date_from, date_to = _resolve_date_range(request) + range_active = bool(date_from or date_to) - if month: - expenses = expenses.filter(date__month=month) + # Un solo criterio de periodo a la vez: si hay rango, manda el rango y los + # selectores de año y mes no se aplican (la plantilla lo avisa). + if range_active: + if date_from: + expenses = expenses.filter(date__gte=date_from) + if date_to: + expenses = expenses.filter(date__lte=date_to) + else: + if year: + expenses = expenses.filter(date__year=year) + if month: + expenses = expenses.filter(date__month=month) if category: expenses = expenses.filter(category_id=category) @@ -407,6 +448,9 @@ def expense_list(request): "page_obj": page_obj, "selected_year": year, "selected_month": month, + "date_from": date_from, + "date_to": date_to, + "range_active": range_active, "selected_category": category, "categories": categories, "year_list": [y.year for y in year_list], From 836f3e8524bdcb4e2301573b37f43a1943dcbfb6 Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:32:43 +0200 Subject: [PATCH 14/17] Aplica el rango de fechas al dashboard Un rango que solo funciona en el listado obliga a cambiar de pantalla para responder "cuanto gaste en ese viaje". El dashboard acepta ahora los mismos date_from y date_to, con la misma precedencia sobre ano y mes. Dos decisiones que no son obvias: La comparativa se desactiva cuando hay rango. Un rango arbitrario no tiene un "periodo anterior" evidente, y elegir uno por el usuario daria un porcentaje que parece significativo sin serlo. Se distingue entre pedirla y poder aplicarla (compare_requested / compare_enabled) para poder avisar de que se ha ignorado en vez de desmarcar la casilla en silencio. El eje del grafico lo decide la longitud del rango, no el mes: por debajo de 62 dias se agrupa por dia y por encima por mes, que es donde un grafico diario deja de leerse. El eje se rellena con ceros en los huecos, igual que ya hacia el resto, para que un periodo sin gastos no colapse la escala. La agrupacion diaria usa el propio campo date y no TruncDate, porque date ya es un DateField y TruncDate le aplicaria una conversion de zona horaria que en SQLite revienta si no esta instalada la base de datos de husos. La tarjeta de proyeccion a fin de mes se oculta con rango activo: proyectar a fin de mes desde un periodo arbitrario no significa nada. Co-Authored-By: Claude Opus 5 --- .../templates/expenses/dashboard.html | 18 +++- expenses_manager/expenses/views.py | 97 +++++++++++++++++-- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/expenses_manager/expenses/templates/expenses/dashboard.html b/expenses_manager/expenses/templates/expenses/dashboard.html index 5bfb08e..f1fdb72 100644 --- a/expenses_manager/expenses/templates/expenses/dashboard.html +++ b/expenses_manager/expenses/templates/expenses/dashboard.html @@ -16,7 +16,10 @@

    - {% if selected_month %} + {% if range_active %} + Del {{ date_from|date:"d/m/Y"|default:"principio" }} + al {{ date_to|date:"d/m/Y"|default:"hoy" }} + {% elif selected_month %} {{ selected_month }}/{{ selected_year}} {% else %} Año {{ selected_year }} @@ -66,6 +69,8 @@ {% endfor %} + {% include "expenses/_date_range.html" %} + {% if not period %} + + {% for y in year_list %} + + {% endfor %} + + + + + + + + + {% include "expenses/_date_range.html" %} + + + Limpiar + + + {% if range_active %} +

    + Mostrando el rango de fechas seleccionado; los filtros de año y mes no + se aplican. + Quitar el rango +

    + {% endif %} + + +
    + ➕ Nuevo ingreso +
    +
    + +
    + Total (sin traspasos): {{ kpi_total|floatformat:2 }}€ | + Ingresos: {{ kpi_count }} +
    +
    @@ -17,10 +81,15 @@ - {% for income in incomes %} + {% for income in page_obj %} - - + + {% endfor %} @@ -40,4 +114,20 @@
    {{ income.name }}{{ income.account }} + {{ income.name }} + {% if income.is_transfer %} + Traspaso + {% endif %} + {{ income.account.name }} {{ income.amount|floatformat:2 }} {{ income.date }} @@ -31,8 +100,13 @@ {% empty %}
    -

    No hay ingresos

    - Añade el primero + {% if filters_active %} +

    No hay ingresos que coincidan con los filtros

    + Quitar los filtros + {% else %} +

    No hay ingresos

    + Añade el primero + {% endif %}
    -{% endblock %} \ No newline at end of file + + +{% endblock %} diff --git a/expenses_manager/expenses/tests/test_income.py b/expenses_manager/expenses/tests/test_income.py index e0e351d..136d180 100644 --- a/expenses_manager/expenses/tests/test_income.py +++ b/expenses_manager/expenses/tests/test_income.py @@ -1,9 +1,25 @@ import pytest from datetime import date +from decimal import Decimal +from django.test.utils import CaptureQueriesContext +from django.db import connection +from django.urls import reverse from expenses.models import Income, Account pytestmark = pytest.mark.django_db + +def make_income(user, account, amount, on, name="Ingreso", is_transfer=False): + return Income.objects.create( + owner=user, + account=account, + name=name, + amount=Decimal(amount), + date=on, + is_transfer=is_transfer, + ) + + def test_income_increases_account_balance(user): general_account = Account.objects.create(name='General', owner=user, initial_balance=1000, active=True) @@ -16,3 +32,140 @@ def test_income_increases_account_balance(user): ) assert general_account.current_balance() == 1500 + + +# -------------------------------------------------------------------------- +# Listado: filtros, paginación y totales +# -------------------------------------------------------------------------- + +def test_income_list_filters_by_year(auth_client, user, account): + old = make_income(user, account, "10", date(2023, 5, 10)) + new = make_income(user, account, "20", date(2024, 5, 10)) + + response = auth_client.get(reverse("income_list"), {"year": 2024}) + + assert list(response.context["page_obj"]) == [new] + assert old not in list(response.context["page_obj"]) + + +def test_income_list_filters_by_month(auth_client, user, account): + march = make_income(user, account, "10", date(2024, 3, 10)) + make_income(user, account, "20", date(2024, 5, 10)) + + response = auth_client.get(reverse("income_list"), {"year": 2024, "month": 3}) + + assert list(response.context["page_obj"]) == [march] + + +def test_income_list_filters_by_account(auth_client, user, account): + other = Account.objects.create( + owner=user, name="Otra", initial_balance=Decimal("0"), active=True + ) + make_income(user, account, "10", date(2024, 3, 10)) + from_other = make_income(user, other, "20", date(2024, 3, 11)) + + response = auth_client.get(reverse("income_list"), {"account": other.id}) + + assert list(response.context["page_obj"]) == [from_other] + + +def test_income_list_filters_by_range(auth_client, user, account): + inside = make_income(user, account, "10", date(2024, 3, 15)) + make_income(user, account, "20", date(2024, 4, 5)) + + response = auth_client.get( + reverse("income_list"), + {"date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + assert response.context["range_active"] is True + assert list(response.context["page_obj"]) == [inside] + + +def test_income_list_range_overrides_year(auth_client, user, account): + in_2023 = make_income(user, account, "10", date(2023, 5, 10)) + make_income(user, account, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("income_list"), + {"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"}, + ) + + assert list(response.context["page_obj"]) == [in_2023] + + +def test_income_list_paginates_by_ten(auth_client, user, account): + for day in range(1, 15): + make_income(user, account, "10", date(2024, 3, day)) + + response = auth_client.get(reverse("income_list")) + + assert len(response.context["page_obj"]) == 10 + assert response.context["page_obj"].paginator.num_pages == 2 + + second = auth_client.get(reverse("income_list"), {"page": 2}) + assert len(second.context["page_obj"]) == 4 + + +def test_income_list_pagination_links_keep_the_filters(auth_client, user, account): + for day in range(1, 15): + make_income(user, account, "10", date(2024, 3, day)) + make_income(user, account, "99", date(2023, 1, 1)) + + response = auth_client.get(reverse("income_list"), {"year": 2024}) + + # query_params alimenta los enlaces de paginación: sin él, pasar de página + # perdería el filtro y aparecería el ingreso de 2023. + assert "year=2024" in response.context["query_params"] + assert "page" not in response.context["query_params"] + assert 'href="?page=2&year=2024"' in response.content.decode() + + +def test_income_list_total_excludes_transfers_but_lists_them(auth_client, user, account): + make_income(user, account, "100", date(2024, 3, 10)) + transfer = make_income( + user, account, "400", date(2024, 3, 11), is_transfer=True + ) + + response = auth_client.get(reverse("income_list")) + + assert response.context["kpi_total"] == Decimal("100") + assert response.context["kpi_count"] == 1 + assert transfer in list(response.context["page_obj"]) + + +def test_income_list_empty_state_when_filters_match_nothing(auth_client, user, account): + make_income(user, account, "10", date(2024, 3, 10)) + + response = auth_client.get(reverse("income_list"), {"year": 1999}) + + assert response.status_code == 200 + assert list(response.context["page_obj"]) == [] + assert response.context["filters_active"] is True + body = response.content.decode() + assert "No hay ingresos que coincidan con los filtros" in body + assert "Añade el primero" not in body + + +def test_income_list_empty_state_without_filters(auth_client): + response = auth_client.get(reverse("income_list")) + + assert response.context["filters_active"] is False + assert "Añade el primero" in response.content.decode() + + +def test_income_list_query_count_does_not_grow_with_rows(auth_client, user, account): + """select_related("account"): una consulta por página, no una por fila.""" + for day in range(1, 4): + make_income(user, account, "10", date(2024, 3, day)) + + with CaptureQueriesContext(connection) as few: + auth_client.get(reverse("income_list")) + + for day in range(4, 11): + make_income(user, account, "10", date(2024, 3, day)) + + with CaptureQueriesContext(connection) as many: + auth_client.get(reverse("income_list")) + + assert len(many) == len(few) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 3bbc8d1..ef29c7b 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -979,12 +979,79 @@ def income_create(request): @login_required def income_list(request): - incomes = Income.objects.filter(owner=request.user) + incomes = Income.objects.filter(owner=request.user).select_related("account") + + year_list = Income.objects.filter(owner=request.user).dates("date", "year") + + months = list(range(1, 13)) + + # Filters + year = _get_int(request.GET.get("year")) + month = _get_int(request.GET.get("month")) + account_id = _get_int(request.GET.get("account")) + + date_from, date_to = _resolve_date_range(request) + range_active = bool(date_from or date_to) + + # Misma precedencia que en expense_list: si hay rango, manda el rango y los + # selectores de año y mes no se aplican (la plantilla lo avisa). + if range_active: + if date_from: + incomes = incomes.filter(date__gte=date_from) + if date_to: + incomes = incomes.filter(date__lte=date_to) + else: + if year: + incomes = incomes.filter(date__year=year) + if month: + incomes = incomes.filter(date__month=month) + + if account_id: + incomes = incomes.filter(account_id=account_id) + + incomes = incomes.order_by("-date") + + # La tabla sigue listando los traspasos, pero el total mide ingreso real y + # los excluye. Por eso la etiqueta dice "sin traspasos" en la plantilla. + totals = incomes.exclude(is_transfer=True).aggregate( + total=Sum("amount"), count=Count("id") + ) + total_amount = totals["total"] or 0 + income_count = totals["count"] + + # Pagination + paginator = Paginator(incomes, 10) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + query_params = request.GET.copy() + query_params.pop("page", None) + + filters_active = bool( + year or month or account_id or range_active + ) return render( request, "expenses/income_list.html", - {"active_menu": "incomes", "incomes": incomes}, + { + "active_menu": "incomes", + "incomes": page_obj, + "page_obj": page_obj, + "year_list": [y.year for y in year_list], + "months": months, + "accounts": Account.objects.filter(owner=request.user), + "selected_year": year, + "selected_month": month, + "selected_account": account_id, + "date_from": date_from, + "date_to": date_to, + "range_active": range_active, + "kpi_total": total_amount, + "kpi_count": income_count, + "filters_active": filters_active, + "query_params": query_params.urlencode(), + }, ) From 8d3aaf111007cbc1671345475801b6780dee568f Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 16 Sep 2026 08:33:20 +0200 Subject: [PATCH 17/17] Distingue el estado vacio filtrado del listado sin datos La tabla de gastos mostraba "No hay gastos / Anade el primero" tanto cuando el usuario no tiene ningun gasto como cuando sus filtros no devuelven nada. En el segundo caso el mensaje miente y ademas empuja a crear un gasto que no hace falta. Ahora, con cualquier filtro puesto (ano, mes, categoria, etiquetas, cuenta o rango), el mensaje dice que no hay gastos que coincidan y ofrece quitarlos. Co-Authored-By: Claude Opus 5 --- .../expenses/templates/expenses/expense_list.html | 9 +++++++-- expenses_manager/expenses/views.py | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html index b484fa0..22037b9 100644 --- a/expenses_manager/expenses/templates/expenses/expense_list.html +++ b/expenses_manager/expenses/templates/expenses/expense_list.html @@ -157,8 +157,13 @@ {% empty %}
  • -

    No hay gastos

    - Añade el primero + {% if filters_active %} +

    No hay gastos que coincidan con los filtros

    + Quitar los filtros + {% else %} +

    No hay gastos

    + Añade el primero + {% endif %}