From 27703bd47c9e5eefa20aba5bf0778c846a76a2fc Mon Sep 17 00:00:00 2001 From: JKuijperM Date: Wed, 14 Oct 2026 02:34:31 +0200 Subject: [PATCH] Add income vs expense series to dashboard --- .../expenses/tests/test_dashboard.py | 207 +++++++++++++++++- expenses_manager/expenses/views.py | 174 ++++++++++----- 2 files changed, 329 insertions(+), 52 deletions(-) diff --git a/expenses_manager/expenses/tests/test_dashboard.py b/expenses_manager/expenses/tests/test_dashboard.py index effc428..3e67faf 100644 --- a/expenses_manager/expenses/tests/test_dashboard.py +++ b/expenses_manager/expenses/tests/test_dashboard.py @@ -1,8 +1,8 @@ import pytest -from datetime import date +from datetime import date, timedelta from decimal import Decimal from django.urls import reverse -from expenses.models import Account, Expense, Category, Income +from expenses.models import Account, Expense, Category, Income, Transfer pytestmark = pytest.mark.django_db @@ -227,6 +227,209 @@ def test_dashboard_comparison_breaks_ties_by_category_name( assert order == ["Alfa", "Media", "Zeta"] +# -------------------------------------------------------------------------- +# Serie de ingresos contra gastos +# -------------------------------------------------------------------------- + + +def _make_transfer(user, from_account, to_account, amount="400", on=None, fee=None): + transfer = Transfer( + owner=user, + date=on or date(2024, 3, 10), + amount=Decimal(amount), + from_account=from_account, + to_account=to_account, + fee_amount=Decimal(fee) if fee is not None else None, + ) + transfer.sync() + return transfer + + +def test_dashboard_sends_both_series_on_the_same_axis( + auth_client, user, account, category +): + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("30"), date=date(2024, 5, 10), + ) + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=date(2024, 3, 10), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + expenses = response.context['chart_data'] + incomes = response.context['chart_income_data'] + # Mismo eje para las dos: si cada una montara el suyo, se desalinearían. + assert len(incomes) == len(response.context['chart_labels']) == len(expenses) + assert incomes[2] == 1500.0 # marzo + assert expenses[4] == 30.0 # mayo + assert incomes[4] == 0 # mayo no tuvo ingresos: cero, no hueco + + +def test_dashboard_income_series_is_zero_filled_without_movements(auth_client): + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert response.context['chart_income_data'] == [0] * 12 + + +def test_dashboard_income_series_is_zero_filled_in_a_month_without_movements( + auth_client +): + response = auth_client.get(reverse('dashboard'), {'year': 2024, 'month': 3}) + + assert response.context['chart_income_data'] == [0] * 31 + + +def test_dashboard_both_series_ignore_a_transfer(user, auth_client, account): + other = Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + _make_transfer(user, account, other, amount="400") + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # Mandarse dinero a una cuenta propia no es ingreso ni gasto: si se + # excluyera solo de los gastos, el gráfico daría un pico de ingresos cada + # vez que el usuario se traspasa la nómina. + assert sum(response.context['chart_income_data']) == 0 + assert sum(response.context['chart_data']) == 0 + + +def test_dashboard_counts_the_transfer_fee_as_an_expense(user, auth_client, account): + other = Account.objects.create( + owner=user, name="Ahorro", initial_balance=Decimal("0"), active=True + ) + _make_transfer(user, account, other, amount="400", fee="1.50") + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + # La comisión va con is_transfer=False: sale del patrimonio de verdad. + assert sum(response.context['chart_data']) == 1.5 + assert sum(response.context['chart_income_data']) == 0 + + +def test_dashboard_income_series_follows_the_day_axis_in_a_short_range( + auth_client, user, account +): + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=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' + incomes = response.context['chart_income_data'] + assert len(incomes) == 30 + assert incomes[4] == 1500.0 + assert incomes[0] == 0 + + +def test_dashboard_income_series_follows_the_month_axis_in_a_long_range( + auth_client, user, account +): + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=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' + incomes = response.context['chart_income_data'] + assert len(incomes) == 4 + assert incomes[1] == 1500.0 + + +def test_dashboard_income_series_matches_the_62_day_threshold( + auth_client, user, account +): + 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()}, + ) + + # La serie de ingresos usa el mismo umbral que la de gastos, no el suyo. + assert at_limit.context['chart_type'] == 'day' + assert len(at_limit.context['chart_income_data']) == 62 + assert over_limit.context['chart_type'] == 'month' + assert len(over_limit.context['chart_income_data']) == 3 # mar, abr, may + + +def test_dashboard_open_range_axis_covers_older_incomes(auth_client, user, account, category): + Income.objects.create( + owner=user, account=account, name="Nómina", + amount=Decimal("1500"), date=date(2024, 3, 1), + ) + Expense.objects.create( + owner=user, account=account, category=category, + amount=Decimal("30"), date=date(2024, 3, 20), + ) + + response = auth_client.get(reverse('dashboard'), {'date_to': '2024-03-25'}) + + # Sin date_from el eje se acota con el movimiento más antiguo de las dos + # series: midiendo solo el gasto más viejo, ese ingreso se caería del eje. + assert response.context['chart_labels'][0] == "01/03" + assert response.context['chart_income_data'][0] == 1500.0 + + +def test_dashboard_income_series_does_not_leak_between_users( + auth_client, user, account, django_user_model +): + intruder = django_user_model.objects.create_user(username="otro", password="x") + their_account = Account.objects.create( + owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True + ) + Income.objects.create( + owner=intruder, account=their_account, name="Ajena", + amount=Decimal("999"), date=date(2024, 3, 10), + ) + Income.objects.create( + owner=user, account=account, name="Mía", + amount=Decimal("100"), date=date(2024, 3, 10), + ) + + response = auth_client.get(reverse('dashboard'), {'year': 2024}) + + assert sum(response.context['chart_income_data']) == 100.0 + + +def test_dashboard_income_series_respects_the_account_filter( + auth_client, user, account +): + other = Account.objects.create( + owner=user, name="Otra", initial_balance=Decimal("0"), active=True + ) + Income.objects.create( + owner=user, account=account, name="Aquí", + amount=Decimal("100"), date=date(2024, 3, 10), + ) + Income.objects.create( + owner=user, account=other, name="Allí", + amount=Decimal("500"), date=date(2024, 3, 10), + ) + + response = auth_client.get( + reverse('dashboard'), {'year': 2024, 'account': account.id} + ) + + assert sum(response.context['chart_income_data']) == 100.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}) diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py index 6d79a78..cdfd28b 100644 --- a/expenses_manager/expenses/views.py +++ b/expenses_manager/expenses/views.py @@ -143,6 +143,16 @@ def _category_tree(categories): return rows +def _without_transfers(movements): + """Quita las patas de traspaso de un queryset de gastos o de ingresos. + + Único sitio donde se decide qué cuenta como movimiento real en los análisis. + Si cada pantalla lo filtrara por su cuenta acabaría habiendo dos criterios, + y bastaría tocar uno para que los números dejaran de cuadrar entre sí. + """ + return movements.exclude(is_transfer=True) + + def _analysis_expenses(user): """Gastos para análisis: excluye los traspasos entre cuentas propias. @@ -154,7 +164,64 @@ def _analysis_expenses(user): 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) + return _without_transfers(Expense.objects.filter(owner=user)) + + +def _analysis_incomes(user): + """Ingresos para análisis: el espejo de _analysis_expenses. + + Hace falta porque si los traspasos se excluyeran solo del lado de los + gastos, el gráfico de ingresos contra gastos daría un pico de ingresos cada + vez que el usuario se traspasa la nómina a otra cuenta propia. + + La misma advertencia que en los gastos: esto no vale para saldos. + """ + return _without_transfers(Income.objects.filter(owner=user)) + + +def _bucket_totals(movements, bucket): + """Suma importes agrupados según el eje del gráfico del dashboard. + + `bucket` lo decide quien construye el eje, y las dos series del gráfico de + ingresos contra gastos lo comparten: si cada una se agrupara por su cuenta, + los ejes divergirían en cuanto una tuviera un hueco que la otra no. + + Son dos consultas separadas (una por serie) que se cruzan después en Python. + No se puede juntar en un annotate() de dos Sum sobre el mismo queryset: el + JOIN multiplica filas e infla los totales. + """ + if bucket == "date": + # 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. + rows = movements.values("date").annotate(total=Sum("amount")) + return {row["date"]: float(row["total"]) for row in rows} + + if bucket == "year_month": + rows = ( + movements.annotate(bucket=TruncMonth("date")) + .values("bucket") + .annotate(total=Sum("amount")) + ) + return { + (row["bucket"].year, row["bucket"].month): float(row["total"]) + for row in rows + } + + if bucket == "day_of_month": + rows = ( + movements.annotate(day=ExtractDay("date")) + .values("day") + .annotate(total=Sum("amount")) + ) + return {row["day"]: float(row["total"]) for row in rows} + + rows = ( + movements.annotate(month=ExtractMonth("date")) + .values("month") + .annotate(total=Sum("amount")) + ) + return {row["month"]: float(row["total"]) for row in rows} def _reject_transfer_leg(request, movement): @@ -720,16 +787,27 @@ def dashboard(request): if account_id: expenses = expenses.filter(account_id=account_id) + # Los ingresos van en paralelo y con los mismos filtros: son la otra serie + # del gráfico de ingresos contra gastos. + incomes = _analysis_incomes(request.user) + if account_id: + incomes = incomes.filter(account_id=account_id) + if range_active: expenses_filtered = expenses + incomes_filtered = incomes if date_from: expenses_filtered = expenses_filtered.filter(date__gte=date_from) + incomes_filtered = incomes_filtered.filter(date__gte=date_from) if date_to: expenses_filtered = expenses_filtered.filter(date__lte=date_to) + incomes_filtered = incomes_filtered.filter(date__lte=date_to) else: expenses_filtered = expenses.filter(date__year=selected_year) + incomes_filtered = incomes.filter(date__year=selected_year) if selected_month: expenses_filtered = expenses_filtered.filter(date__month=selected_month) + incomes_filtered = incomes_filtered.filter(date__month=selected_month) # Basic KPIs @@ -760,20 +838,35 @@ def dashboard(request): }) #Graphic + # El eje se construye una sola vez (claves + etiquetas + agrupación) y las + # dos series del gráfico de ingresos contra gastos se calculan contra él. + # Si cada serie montara su propio eje, dos huecos distintos las + # desalinearían y el gráfico mentiría. daily_average = 0 projected_end_of_month = 0 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"] + # Si falta un extremo se acota para poder medirlo: por abajo con el + # movimiento más antiguo del conjunto, por arriba con hoy. + span_from = date_from + if span_from is None: + # Se mira el más antiguo de LAS DOS series: acotando solo por el + # gasto más viejo, los ingresos anteriores se caerían del eje sin + # que nada lo avisara. + firsts = [ + expenses_filtered.aggregate(first=Min("date"))["first"], + incomes_filtered.aggregate(first=Min("date"))["first"], + ] + firsts = [first for first in firsts if first is not None] + span_from = min(firsts) if firsts else None span_to = date_to or today if span_from is None: - # Sin gastos y sin fecha inicial no hay longitud que medir. + # Sin movimientos y sin fecha inicial no hay longitud que medir. + axis_keys = [] chart_labels = [] - chart_totals = [] chart_type = "month" + bucket = "year_month" else: span_days = (span_to - span_from).days + 1 daily_average = total_amount / span_days if span_days > 0 else 0 @@ -781,71 +874,49 @@ def dashboard(request): # 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] + axis_keys = [span_from + timedelta(days=i) for i in range(span_days)] + chart_labels = [d.strftime("%d/%m") for d in axis_keys] chart_type = "day" + bucket = "date" 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 = [] + axis_keys = [] cursor = (span_from.year, span_from.month) last = (span_to.year, span_to.month) while cursor <= last: - axis.append(cursor) + axis_keys.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_labels = [f"{m:02d}/{y}" for y, m in axis_keys] chart_type = "month" + bucket = "year_month" elif selected_month: - by_day_qs = ( - expenses_filtered.annotate(day=ExtractDay("date")) - .values("day") - .annotate(total=Sum("amount")) - ) - day_totals = {row["day"]: float(row["total"]) for row in by_day_qs} - num_days = calendar.monthrange(selected_year, selected_month)[1] if selected_year == today.year and selected_month == today.month: days_passed = today.day else: days_passed = calendar.monthrange(selected_year, selected_month)[1] - + daily_average = total_amount / days_passed if days_passed > 0 else 0 total_days_in_month = calendar.monthrange(selected_year, selected_month)[1] projected_end_of_month = daily_average * total_days_in_month - - chart_labels = list(range(1, num_days + 1)) - chart_totals = [day_totals.get(d, 0) for d in chart_labels] + + axis_keys = list(range(1, num_days + 1)) + chart_labels = axis_keys chart_type = "day" + bucket = "day_of_month" else: - by_month_qs = ( - expenses_filtered.annotate(month=ExtractMonth("date")) - .values("month") - .annotate(total=Sum("amount")) - ) - month_totals = {row["month"]: float(row["total"]) for row in by_month_qs} + axis_keys = list(range(1, 13)) chart_labels = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"] - chart_totals = [month_totals.get(m, 0) for m in range(1, 13)] chart_type = "month" + bucket = "month_of_year" + + # Los ceros son obligatorios: un día o un mes sin movimientos tiene que + # salir con 0, no desaparecer del eje. + expense_by_bucket = _bucket_totals(expenses_filtered, bucket) + income_by_bucket = _bucket_totals(incomes_filtered, bucket) + chart_totals = [expense_by_bucket.get(key, 0) for key in axis_keys] + chart_income_totals = [income_by_bucket.get(key, 0) for key in axis_keys] year_list = ( Expense.objects.filter(owner=request.user) @@ -931,6 +1002,9 @@ def dashboard(request): "by_category_chart": by_category_chart, "chart_labels": chart_labels, "chart_data": chart_totals, + # La otra serie del gráfico de ingresos contra gastos. Se manda una + # sola vez: el neto y los tres modos se resuelven en cliente. + "chart_income_data": chart_income_totals, "chart_type": chart_type, "year_list": year_list, "months": list(range(1, 13)), @@ -1166,7 +1240,7 @@ def income_list(request): # 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( + totals = _without_transfers(incomes).aggregate( total=Sum("amount"), count=Count("id") ) total_amount = totals["total"] or 0