diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py
index 14ca8d8..97e0e19 100644
--- a/expenses_manager/expenses/models.py
+++ b/expenses_manager/expenses/models.py
@@ -391,6 +391,9 @@ class Goal(models.Model):
if self.kind == self.KIND_SAVING:
# Provisional: saldo de la cuenta asociada. Cuando exista el
# módulo de inversiones, esta rama es lo único que hay que tocar.
+ # Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de
+ # ahorro es precisamente cómo se progresa en un objetivo de ahorro,
+ # así que no filtres is_transfer en esta rama.
return self.account.current_balance() if self.account else Decimal("0")
if not self.category:
@@ -401,9 +404,11 @@ class Goal(models.Model):
else:
category_ids = [self.category_id]
+ # Pago y presupuesto miden gasto real, así que los traspasos entre
+ # cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids
- )
+ ).exclude(is_transfer=True)
start = self._period_start()
if start:
diff --git a/expenses_manager/expenses/templates/expenses/expense_list.html b/expenses_manager/expenses/templates/expenses/expense_list.html
index 11b6134..10db769 100644
--- a/expenses_manager/expenses/templates/expenses/expense_list.html
+++ b/expenses_manager/expenses/templates/expenses/expense_list.html
@@ -96,7 +96,7 @@
- Total: {{ kpi_total|floatformat:2 }}€ |
+ Total (sin traspasos): {{ kpi_total|floatformat:2 }}€ |
Gastos: {{ kpi_count }} |
Categorías: {{ kpi_categories }}
diff --git a/expenses_manager/expenses/views.py b/expenses_manager/expenses/views.py
index 4ad3976..c9864c1 100644
--- a/expenses_manager/expenses/views.py
+++ b/expenses_manager/expenses/views.py
@@ -77,9 +77,26 @@ def _category_tree(categories):
return rows
+def _analysis_expenses(user):
+ """Gastos para análisis: excluye los traspasos entre cuentas propias.
+
+ Un traspaso mueve dinero entre cuentas del usuario, así que suma al saldo de
+ cada cuenta pero NO es gasto real. Usa este helper en KPIs, gráficos y
+ comparativas.
+
+ NO lo uses en cálculos de saldo (_account_balances, current_balance,
+ monthly_balance, balance_until, monthly_net): ahí los traspasos SÍ cuentan,
+ y filtrarlos descuadraría todos los saldos.
+ """
+ return Expense.objects.filter(owner=user).exclude(is_transfer=True)
+
+
def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de
- account.current_balance() en bucle, que dispararía N+1)."""
+ account.current_balance() en bucle, que dispararía N+1).
+
+ Los traspasos cuentan aquí a propósito: son movimientos reales de las
+ cuentas. No metas _analysis_expenses en esta función."""
account_ids = [a.id for a in accounts]
expense_totals = {
@@ -176,7 +193,9 @@ def _build_account_charts(accounts, balance_by_account, selected_year, today):
@login_required
def home(request):
today = date.today()
- expenses = Expense.objects.filter(owner=request.user)
+ # Base de los KPIs, la comparativa y el mini-gráfico: sin traspasos.
+ # Los últimos movimientos usan su propia query, que sí los incluye.
+ expenses = _analysis_expenses(request.user)
# ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
@@ -214,9 +233,14 @@ def home(request):
has_alerts = bool(exceeded_goals or negative_accounts)
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
- recent_expenses = expenses.select_related("category", "account").order_by(
- "-date", "-id"
- )[:8]
+ # Es un listado, no un análisis: los traspasos son movimientos reales de las
+ # cuentas y salen marcados con un distintivo, así que no se cuelga de
+ # "expenses" (que ya los ha filtrado) sino de la query sin filtrar.
+ recent_expenses = (
+ Expense.objects.filter(owner=request.user)
+ .select_related("category", "account")
+ .order_by("-date", "-id")[:8]
+ )
recent_incomes = (
Income.objects.filter(owner=request.user)
.select_related("account")
@@ -329,11 +353,16 @@ def expense_list(request):
expenses = expenses.order_by("-date")
- total_amount = expenses.aggregate(total=Sum("amount"))["total"] or 0
+ # La tabla sigue listando los traspasos (hay que poder verlos y corregirlos),
+ # pero el total y los contadores miden gasto real, así que los excluyen. Por
+ # eso la etiqueta del total dice "sin traspasos" en la plantilla.
+ analysis_expenses = expenses.exclude(is_transfer=True)
- expense_count = expenses.count()
+ total_amount = analysis_expenses.aggregate(total=Sum("amount"))["total"] or 0
- category_count = expenses.values("category").distinct().count()
+ expense_count = analysis_expenses.count()
+
+ category_count = analysis_expenses.values("category").distinct().count()
# Pagination
paginator = Paginator(expenses, 10)
@@ -490,7 +519,9 @@ def dashboard(request):
kpi_balance = total_balance
# Filter by base expenses
- expenses = Expense.objects.filter(owner=request.user)
+ # Todo lo que cuelga de aquí es análisis (KPIs, gráficos, comparativa), así
+ # que va sin traspasos. Los saldos de arriba sí los incluyen.
+ expenses = _analysis_expenses(request.user)
if account_id:
expenses = expenses.filter(account_id=account_id)
@@ -576,7 +607,7 @@ def dashboard(request):
category_comparison = []
if compare_enabled:
- previous_expenses = Expense.objects.filter(owner=request.user)
+ previous_expenses = _analysis_expenses(request.user)
if account_id:
previous_expenses = previous_expenses.filter(account_id=account_id)