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/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..97e0e19 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: @@ -370,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: @@ -380,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/static/expenses/css/base.css b/expenses_manager/expenses/static/expenses/css/base.css index e827586..6593c5a 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 */ /* ========================= */ @@ -273,7 +298,7 @@ a.danger:visited, margin-bottom: 1rem; } -.dashboard-context h2 { +.dashboard-context h1 { margin: 0; } @@ -371,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; @@ -381,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; } @@ -707,6 +740,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; } @@ -898,7 +938,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/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/dashboard.html b/expenses_manager/expenses/templates/expenses/dashboard.html index f9ff28b..f1fdb72 100644 --- a/expenses_manager/expenses/templates/expenses/dashboard.html +++ b/expenses_manager/expenses/templates/expenses/dashboard.html @@ -7,16 +7,19 @@ {% block content %}
-

+

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

+

- {% 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 %}

@@ -212,21 +198,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

@@ -243,7 +229,12 @@ - + @@ -290,11 +281,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 }}€

@@ -393,28 +384,11 @@ - {% if compare_enabled %} -
-

Comparativa

- -

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

-
- {% endif %} - -

Objetivos

+

Objetivos

{% if goals %}
{% for goal in goals %} diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html index 11b6134..22037b9 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 %} +
- Total: {{ kpi_total|floatformat:2 }}€ | + Total (sin traspasos): {{ kpi_total|floatformat:2 }}€ | Gastos: {{ kpi_count }} | Categorías: {{ kpi_categories }}
@@ -117,7 +127,12 @@ {% for expense in page_obj %}
- + {% endfor %} 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/templates/expenses/income_list.html b/expenses_manager/expenses/templates/expenses/income_list.html index 7c9fb4d..56820bb 100644 --- a/expenses_manager/expenses/templates/expenses/income_list.html +++ b/expenses_manager/expenses/templates/expenses/income_list.html @@ -3,8 +3,72 @@ {% block content %}

    Mis ingresos

    + +
    + + + + + + + + + + + {% 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 }} +
    +
  • {{ 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 }} @@ -142,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 %}
    @@ -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/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 - -
    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

    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 diff --git a/expenses_manager/expenses/tests/test_date_range.py b/expenses_manager/expenses/tests/test_date_range.py new file mode 100644 index 0000000..445e0e0 --- /dev/null +++ b/expenses_manager/expenses/tests/test_date_range.py @@ -0,0 +1,271 @@ +"""Rango de fechas libre (date_from / date_to) en expense_list y dashboard. + +La regla que estructura todo: si hay rango activo, manda el rango y los +selectores de año y mes no se aplican. Un solo criterio de periodo a la vez. +""" +import pytest +from datetime import date, timedelta +from decimal import Decimal +from django.urls import reverse +from expenses.models import Expense + +pytestmark = pytest.mark.django_db + + +def make_expense(user, account, category, amount, on): + return Expense.objects.create( + owner=user, + account=account, + category=category, + amount=Decimal(amount), + date=on, + ) + + +# -------------------------------------------------------------------------- +# expense_list +# -------------------------------------------------------------------------- + +def test_expense_list_filters_by_range(auth_client, user, account, category): + inside = make_expense(user, account, category, "10", date(2024, 3, 15)) + before = make_expense(user, account, category, "20", date(2024, 2, 20)) + after = make_expense(user, account, category, "30", date(2024, 4, 5)) + + response = auth_client.get( + reverse("expense_list"), + {"date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + rows = list(response.context["page_obj"]) + assert rows == [inside] + assert before not in rows and after not in rows + assert response.context["range_active"] is True + assert response.context["kpi_total"] == Decimal("10") + + +def test_expense_list_open_ended_range_reaches_the_end(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 2, 20)) + march = make_expense(user, account, category, "20", date(2024, 3, 15)) + december = make_expense(user, account, category, "30", date(2024, 12, 31)) + + response = auth_client.get(reverse("expense_list"), {"date_from": "2024-03-01"}) + + rows = list(response.context["page_obj"]) + assert set(rows) == {march, december} + + +def test_expense_list_open_ended_range_reaches_the_start(auth_client, user, account, category): + old = make_expense(user, account, category, "10", date(2020, 1, 1)) + february = make_expense(user, account, category, "20", date(2024, 2, 20)) + make_expense(user, account, category, "30", date(2024, 4, 5)) + + response = auth_client.get(reverse("expense_list"), {"date_to": "2024-03-01"}) + + rows = list(response.context["page_obj"]) + assert set(rows) == {old, february} + + +def test_expense_list_inverted_range_is_ignored_entirely(auth_client, user, account, category): + """Un rango invertido no debe devolver una lista vacía sin explicación.""" + expenses = [ + make_expense(user, account, category, "10", date(2024, 2, 20)), + make_expense(user, account, category, "20", date(2024, 3, 15)), + ] + + response = auth_client.get( + reverse("expense_list"), + {"date_from": "2024-12-01", "date_to": "2024-01-01"}, + ) + + assert response.context["range_active"] is False + assert set(response.context["page_obj"]) == set(expenses) + + +def test_expense_list_malformed_date_is_ignored(auth_client, user, account, category): + expense = make_expense(user, account, category, "10", date(2024, 3, 15)) + + response = auth_client.get(reverse("expense_list"), {"date_from": "loquesea"}) + + assert response.status_code == 200 + assert response.context["range_active"] is False + assert list(response.context["page_obj"]) == [expense] + + +def test_expense_list_range_overrides_year_and_month(auth_client, user, account, category): + """El año viene en la URL pero no debe aplicarse: manda el rango.""" + in_2023 = make_expense(user, account, category, "10", date(2023, 5, 10)) + make_expense(user, account, category, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("expense_list"), + {"year": 2024, "month": 5, "date_from": "2023-01-01", "date_to": "2023-12-31"}, + ) + + assert list(response.context["page_obj"]) == [in_2023] + + +# -------------------------------------------------------------------------- +# dashboard +# -------------------------------------------------------------------------- + +def test_dashboard_filters_by_range(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 15)) + make_expense(user, account, category, "20", date(2024, 4, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + assert response.context["range_active"] is True + assert response.context["kpi_total"] == Decimal("10") + assert response.context["kpi_count"] == 1 + + +def test_dashboard_range_overrides_year(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2023, 5, 10)) + make_expense(user, account, category, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("dashboard"), + {"year": 2024, "date_from": "2023-01-01", "date_to": "2023-12-31"}, + ) + + assert response.context["kpi_total"] == Decimal("10") + + +def test_dashboard_inverted_range_falls_back_to_the_normal_period( + auth_client, user, account, category +): + make_expense(user, account, category, "20", date(2024, 5, 10)) + + response = auth_client.get( + reverse("dashboard"), + {"year": 2024, "date_from": "2024-12-01", "date_to": "2024-01-01"}, + ) + + assert response.context["range_active"] is False + assert response.context["kpi_total"] == Decimal("20") + + +def test_dashboard_short_range_charts_by_day(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-03-01", "date_to": "2024-03-30"}, # 30 días + ) + + assert response.context["chart_type"] == "day" + labels = response.context["chart_labels"] + assert len(labels) == 30 + assert labels[0] == "01/03" + assert labels[4] == "05/03" + # El eje es denso: el día con gasto lleva su importe, el resto va a cero. + assert response.context["chart_data"][4] == 10.0 + assert response.context["chart_data"][0] == 0 + + +def test_dashboard_long_range_charts_by_month(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 2, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-01-01", "date_to": "2024-04-29"}, # 120 días + ) + + assert response.context["chart_type"] == "month" + labels = response.context["chart_labels"] + assert labels == ["01/2024", "02/2024", "03/2024", "04/2024"] + assert response.context["chart_data"][1] == 10.0 + + +def test_dashboard_range_threshold_is_62_days(auth_client, user, account, category): + start = date(2024, 3, 1) + + at_limit = auth_client.get( + reverse("dashboard"), + {"date_from": start.isoformat(), "date_to": (start + timedelta(days=61)).isoformat()}, + ) + over_limit = auth_client.get( + reverse("dashboard"), + {"date_from": start.isoformat(), "date_to": (start + timedelta(days=62)).isoformat()}, + ) + + assert at_limit.context["chart_type"] == "day" # 62 días justos + assert over_limit.context["chart_type"] == "month" # 63 días + + +def test_dashboard_range_disables_comparison(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 15)) + + response = auth_client.get( + reverse("dashboard"), + {"compare": "1", "date_from": "2024-03-01", "date_to": "2024-03-31"}, + ) + + assert response.context["compare_enabled"] is False + assert response.context["compare_suppressed"] is True + assert response.context["kpi_previous_total"] == 0 + assert response.context["category_comparison"] == [] + + +def test_dashboard_range_skips_the_end_of_month_projection( + auth_client, user, account, category +): + today = date.today() + make_expense(user, account, category, "10", today) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": today.replace(day=1).isoformat()}, + ) + + assert response.context["projected_end_of_month"] == 0 + + +def test_dashboard_range_daily_average_uses_the_range_length( + auth_client, user, account, category +): + make_expense(user, account, category, "100", date(2024, 3, 5)) + + response = auth_client.get( + reverse("dashboard"), + {"date_from": "2024-03-01", "date_to": "2024-03-10"}, # 10 días + ) + + assert response.context["daily_average"] == Decimal("10") + + +def test_dashboard_empty_open_range_does_not_break(auth_client, user): + """Sin date_from y sin gastos no hay longitud que medir: eje mensual vacío.""" + response = auth_client.get(reverse("dashboard"), {"date_to": "2024-03-31"}) + + assert response.status_code == 200 + assert response.context["chart_type"] == "month" + assert response.context["chart_labels"] == [] + + +# -------------------------------------------------------------------------- +# Sin rango no cambia nada +# -------------------------------------------------------------------------- + +def test_dashboard_without_range_keeps_the_old_day_axis(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 5)) + + response = auth_client.get(reverse("dashboard"), {"year": 2024, "month": 3}) + + assert response.context["range_active"] is False + assert response.context["chart_type"] == "day" + # Números de día, no fechas. + assert response.context["chart_labels"] == list(range(1, 32)) + + +def test_dashboard_without_range_keeps_the_old_month_axis(auth_client, user, account, category): + make_expense(user, account, category, "10", date(2024, 3, 5)) + + response = auth_client.get(reverse("dashboard"), {"year": 2024}) + + assert response.context["chart_type"] == "month" + assert response.context["chart_labels"][0] == "Ene" + assert len(response.context["chart_labels"]) == 12 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/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 diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 34d77ac..3dd4a08 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -1,6 +1,6 @@ import logging import calendar -from datetime import date, datetime +from datetime import date, datetime, timedelta from decimal import Decimal from django.contrib import messages from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal @@ -15,8 +15,14 @@ from .forms import ( ) from django.core.paginator import Paginator -from django.db.models import Sum, Count, ProtectedError -from django.db.models.functions import ExtractMonth, ExtractYear, ExtractDay, Lower +from django.db.models import Sum, Count, Min, ProtectedError +from django.db.models.functions import ( + ExtractMonth, + ExtractYear, + ExtractDay, + Lower, + TruncMonth, +) from django.contrib.auth.decorators import login_required from django.utils.http import url_has_allowed_host_and_scheme @@ -31,6 +37,37 @@ def _get_int(value): return None +# Umbral del eje del gráfico con rango libre: dos meses es donde un gráfico +# diario deja de leerse, así que por encima se agrupa por mes. +RANGE_DAY_CHART_MAX_DAYS = 62 + + +def _get_date(value): + """Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida.""" + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except (TypeError, ValueError): + return None + + +def _resolve_date_range(request): + """Lee el rango de fechas libre de la URL. + + Devuelve (date_from, date_to); cualquiera de los dos puede ser None, porque + "desde el 1 de marzo" sin fecha final es un filtro válido. + + Si el rango está invertido (desde > 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") @@ -77,9 +114,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 = { @@ -112,10 +166,73 @@ 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() - 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) @@ -153,9 +270,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") @@ -170,6 +292,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 ] @@ -180,6 +303,7 @@ def home(request): "label": i.name, "account": i.account.name, "amount": i.amount, + "is_transfer": i.is_transfer, } for i in recent_incomes ], @@ -249,11 +373,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) @@ -268,11 +402,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) @@ -306,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", @@ -315,6 +458,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], @@ -328,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(), }, ) @@ -407,43 +554,54 @@ 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" - + compare_requested = 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) + + # Rango de fechas libre. Si está activo manda sobre año y mes, y además + # desactiva la comparativa: no hay un "periodo anterior" obvio de un rango + # arbitrario. compare_suppressed sirve para decírselo al usuario. + date_from, date_to = _resolve_date_range(request) + range_active = bool(date_from or date_to) + compare_enabled = compare_requested and not range_active + compare_suppressed = compare_requested and range_active + # 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) + # 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) - expenses_filtered = expenses.filter(date__year=selected_year) - if selected_month: - expenses_filtered = expenses_filtered.filter(date__month=selected_month) - + if range_active: + expenses_filtered = expenses + if date_from: + expenses_filtered = expenses_filtered.filter(date__gte=date_from) + if date_to: + expenses_filtered = expenses_filtered.filter(date__lte=date_to) + else: + expenses_filtered = expenses.filter(date__year=selected_year) + if selected_month: + expenses_filtered = expenses_filtered.filter(date__month=selected_month) + + # Basic KPIs total_amount = expenses_filtered.aggregate(total=Sum("amount"))["total"] or 0 expense_count = expenses_filtered.count() @@ -474,7 +632,60 @@ def dashboard(request): #Graphic daily_average = 0 projected_end_of_month = 0 - if selected_month: + if range_active: + # Con rango libre el eje no lo decide el mes sino la longitud del rango. + # Si falta un extremo se acota para poder medirlo: por abajo con el gasto + # más antiguo del conjunto, por arriba con hoy. + span_from = date_from or expenses_filtered.aggregate(first=Min("date"))["first"] + span_to = date_to or today + + if span_from is None: + # Sin gastos y sin fecha inicial no hay longitud que medir. + chart_labels = [] + chart_totals = [] + chart_type = "month" + else: + span_days = (span_to - span_from).days + 1 + daily_average = total_amount / span_days if span_days > 0 else 0 + # projected_end_of_month se queda en 0: proyectar a fin de mes desde + # un rango arbitrario no significa nada. + + if span_days <= RANGE_DAY_CHART_MAX_DAYS: + # Agrupar por el propio campo, no con TruncDate: "date" ya es un + # DateField y TruncDate le aplicaría una conversión de zona + # horaria que en SQLite falla si no está la base de datos tz. + by_day_qs = expenses_filtered.values("date").annotate( + total=Sum("amount") + ) + day_totals = {row["date"]: float(row["total"]) for row in by_day_qs} + + axis = [span_from + timedelta(days=i) for i in range(span_days)] + chart_labels = [d.strftime("%d/%m") for d in axis] + chart_totals = [day_totals.get(d, 0) for d in axis] + chart_type = "day" + else: + by_month_qs = ( + expenses_filtered.annotate(bucket=TruncMonth("date")) + .values("bucket") + .annotate(total=Sum("amount")) + ) + month_totals = { + (row["bucket"].year, row["bucket"].month): float(row["total"]) + for row in by_month_qs + } + + axis = [] + cursor = (span_from.year, span_from.month) + last = (span_to.year, span_to.month) + while cursor <= last: + axis.append(cursor) + y, m = cursor + cursor = (y + 1, 1) if m == 12 else (y, m + 1) + + chart_labels = [f"{m:02d}/{y}" for y, m in axis] + chart_totals = [month_totals.get(key, 0) for key in axis] + chart_type = "month" + elif selected_month: by_day_qs = ( expenses_filtered.annotate(day=ExtractDay("date")) .values("day") @@ -522,7 +733,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) @@ -561,33 +772,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) @@ -605,10 +793,15 @@ 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, "selected_month": selected_month, + "date_from": date_from, + "date_to": date_to, + "range_active": range_active, + "compare_suppressed": compare_suppressed, "kpi_total": total_amount, "kpi_count": expense_count, "kpi_categories": category_count, @@ -791,12 +984,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(), + }, )