Add income vs expense series to dashboard

This commit is contained in:
JKuijperM 2026-10-14 02:34:31 +02:00
parent 3c313aa4e4
commit 27703bd47c
2 changed files with 329 additions and 52 deletions

View File

@ -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})

View File

@ -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