Compare commits

...

7 Commits

Author SHA1 Message Date
466e51c703 Merge branches 'dev' and 'dev' of https://gitea.kuijper.es/jkuijperm/expenses_manager into dev 2026-09-29 17:34:02 +02:00
b8d90c08af test: cobertura de traspasos
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:52:30 +02:00
d37cf0800d fix: blank=True en las patas de Transfer
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:52:29 +02:00
488e370a04 feat: redirección del check is_transfer al formulario de traspaso
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:52:13 +02:00
0dd34ab5dc feat: CRUD de traspasos
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:51:16 +02:00
9af6e749d5 feat: migración de datos de traspasos heredados
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:50:52 +02:00
7fd2f3361d feat: modelo Transfer e is_system en Category
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:50:45 +02:00
21 changed files with 1902 additions and 24 deletions

View File

@ -1,5 +1,27 @@
from django import forms from django import forms
from .models import Expense, Category, Income, Tag, Account, FuelEntry, Goal from .models import (
Expense,
Category,
Income,
Tag,
Account,
FuelEntry,
Goal,
Transfer,
)
def _hide_is_transfer_when_editing(form):
"""Quita la casilla de traspaso al editar un movimiento que ya existe.
Al crear, la casilla es un atajo: lleva al formulario de traspaso, que es
quien crea las dos patas. Al editar no significa nada, porque convertir un
gasto suelto en traspaso no es editarlo (habría que inventarse la otra
cuenta), y marcarla solo dejaría el movimiento marcado pero sin pareja: justo
el estado a medias que Transfer viene a evitar.
"""
if form.instance.pk:
form.fields.pop("is_transfer", None)
class ExpenseForm(forms.ModelForm): class ExpenseForm(forms.ModelForm):
@ -18,6 +40,8 @@ class ExpenseForm(forms.ModelForm):
) )
self.fields["date"].input_formats = ["%Y-%m-%d"] self.fields["date"].input_formats = ["%Y-%m-%d"]
_hide_is_transfer_when_editing(self)
class Meta: class Meta:
model = Expense model = Expense
fields = [ fields = [
@ -61,6 +85,37 @@ class IncomeForm(forms.ModelForm):
owner=user, owner=user,
active=True, active=True,
) )
_hide_is_transfer_when_editing(self)
class TransferForm(forms.ModelForm):
"""Formulario del traspaso. Las patas no se editan aquí ni en ninguna parte:
las deriva Transfer.sync() de estos campos."""
class Meta:
model = Transfer
fields = ["date", "amount", "from_account", "to_account", "fee_amount"]
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
labels = {
"from_account": "Cuenta de origen",
"to_account": "Cuenta de destino",
"fee_amount": "Comisión",
}
help_texts = {
"fee_amount": (
"Déjalo vacío si el traspaso no tiene comisión. La comisión sí "
"cuenta como gasto real en el dashboard y en los presupuestos."
),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop("user")
super().__init__(*args, **kwargs)
accounts = Account.objects.filter(owner=user, active=True)
self.fields["from_account"].queryset = accounts
self.fields["to_account"].queryset = accounts
self.fields["date"].input_formats = ["%Y-%m-%d"]
class FuelEntryForm(forms.ModelForm): class FuelEntryForm(forms.ModelForm):

View File

@ -0,0 +1,40 @@
# Generated by Django 5.2.10 on 2026-09-18 08:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0013_expense_is_transfer_income_is_transfer'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='category',
name='is_system',
field=models.BooleanField(default=False, help_text='Categoría creada por la app (traspasos, comisiones). No borrable.'),
),
migrations.CreateModel(
name='Transfer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
('fee_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expense_leg', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_out', to='expenses.expense')),
('fee_expense', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_fee', to='expenses.expense')),
('from_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfers_out', to='expenses.account')),
('income_leg', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_in', to='expenses.income')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transfers', to=settings.AUTH_USER_MODEL)),
('to_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfers_in', to='expenses.account')),
],
options={
'ordering': ['-date'],
},
),
]

View File

@ -0,0 +1,72 @@
"""Convierte en Transfer los traspasos históricos que estaban sueltos.
Antes de que existiera Transfer, un traspaso eran dos movimientos marcados con
is_transfer pero sin nada que los relacionara: un Expense en la cuenta de origen
y un Income en la de destino. Aquí se emparejan y se les cuelga un Transfer.
No se pueden emparejar con certeza: nada en la base de datos dice qué gasto va
con qué ingreso. El criterio es mismo owner, mismo importe, misma fecha y cuentas
distintas, y solo cuando en ese grupo hay exactamente un gasto y un ingreso. Los
ambiguos (varios candidatos con el mismo importe y fecha) y los huérfanos (sin
pareja) se dejan tal cual, con su is_transfer=True: siguen contando en los saldos
y fuera de los análisis igual que antes, solo que sin Transfer que los orqueste.
Emparejarlos a ojo sería inventarse datos del usuario.
"""
from collections import defaultdict
from django.db import migrations
def link_legacy_transfers(apps, schema_editor):
Expense = apps.get_model("expenses", "Expense")
Income = apps.get_model("expenses", "Income")
Transfer = apps.get_model("expenses", "Transfer")
# Solo las patas que aún no pertenecen a ningún Transfer.
expenses_by_key = defaultdict(list)
for expense in Expense.objects.filter(
is_transfer=True, transfer_out__isnull=True, transfer_fee__isnull=True
):
expenses_by_key[(expense.owner_id, expense.amount, expense.date)].append(
expense
)
incomes_by_key = defaultdict(list)
for income in Income.objects.filter(is_transfer=True, transfer_in__isnull=True):
incomes_by_key[(income.owner_id, income.amount, income.date)].append(income)
for key, expenses in expenses_by_key.items():
incomes = incomes_by_key.get(key, [])
# Uno y solo uno de cada lado: cualquier otra cosa es ambigua.
if len(expenses) != 1 or len(incomes) != 1:
continue
expense, income = expenses[0], incomes[0]
if expense.account_id == income.account_id:
continue
Transfer.objects.create(
owner_id=expense.owner_id,
date=expense.date,
amount=expense.amount,
from_account_id=expense.account_id,
to_account_id=income.account_id,
expense_leg=expense,
income_leg=income,
)
class Migration(migrations.Migration):
dependencies = [
("expenses", "0014_category_is_system_transfer"),
]
operations = [
# Sin marcha atrás: deshacer esto es deshacer 0014, que se lleva la tabla
# Transfer entera y deja las patas intactas, que es el estado de partida.
migrations.RunPython(link_legacy_transfers, migrations.RunPython.noop),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.10 on 2026-09-21 13:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0015_link_legacy_transfers'),
]
operations = [
migrations.AlterField(
model_name='transfer',
name='expense_leg',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_out', to='expenses.expense'),
),
migrations.AlterField(
model_name='transfer',
name='income_leg',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transfer_in', to='expenses.income'),
),
]

View File

@ -1,6 +1,7 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.db import models from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction
from django.conf import settings from django.conf import settings
from django.db.models import Sum from django.db.models import Sum
from functools import cached_property from functools import cached_property
@ -26,6 +27,11 @@ class Category(models.Model):
related_name="children", related_name="children",
) )
is_system = models.BooleanField(
default=False,
help_text="Categoría creada por la app (traspasos, comisiones). No borrable.",
)
class Meta: class Meta:
unique_together = ("name", "parent", "owner", "slug") unique_together = ("name", "parent", "owner", "slug")
verbose_name_plural = "categories" verbose_name_plural = "categories"
@ -246,6 +252,22 @@ class Expense(models.Model):
def __str__(self): def __str__(self):
return "{} - {}".format(self.date, self.amount) return "{} - {}".format(self.date, self.amount)
@property
def transfer(self):
"""El Transfer del que este gasto es una pata, o None si es normal.
Cubre las dos patas de gasto de un traspaso: la de salida
(transfer_out) y la comisión (transfer_fee). Un gasto que pertenece a un
traspaso no se edita ni se borra por separado: se hace desde el
traspaso, que es quien mantiene las tres patas cuadradas.
"""
for relation in ("transfer_out", "transfer_fee"):
try:
return getattr(self, relation)
except ObjectDoesNotExist:
continue
return None
class Income(models.Model): class Income(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@ -275,6 +297,220 @@ class Income(models.Model):
def __str__(self): def __str__(self):
return f"{self.name} - {self.amount}" return f"{self.name} - {self.amount}"
@property
def transfer(self):
"""El Transfer del que este ingreso es la pata de entrada, o None."""
try:
return self.transfer_in
except ObjectDoesNotExist:
return None
def _system_category(user, slug, name):
"""Categoría de sistema del usuario (traspasos, comisiones), creándola si falta.
Se busca por slug y no por nombre porque el nombre lo puede cambiar el
usuario; el slug es el identificador estable.
"""
cat, _ = Category.objects.get_or_create(
slug=slug,
owner=user,
defaults={"name": name, "is_system": True},
)
return cat
class Transfer(models.Model):
"""Un traspaso entre dos cuentas propias.
No sustituye a los dos movimientos que ya registraban un traspaso: los
orquesta. Sigue habiendo un Expense en la cuenta origen y un Income en la
destino, los dos con is_transfer=True, pero ahora están vinculados aquí y
es este modelo quien los crea, los edita y los borra a la vez (sync() y
delete()).
Por eso los cuatro métodos de saldo de Account (current_balance,
monthly_balance, balance_until, monthly_net) no saben nada de Transfer y no
necesitan saberlo: el traspaso ya está dentro de sus sumas, porque sus dos
patas siguen siendo un Income y un Expense reales.
"""
TRANSFER_CATEGORY_SLUG = "traspaso"
FEE_CATEGORY_SLUG = "comision"
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transfers"
)
date = models.DateField()
# max_digits=12 frente a los 10 de Expense.amount: un traspaso de más de diez
# dígitos no cabría en su pata de gasto. Se deja así a propósito, porque
# igualarlo obliga a migrar Expense por un caso de 100 millones de euros.
amount = models.DecimalField(max_digits=12, decimal_places=2)
from_account = models.ForeignKey(
Account, on_delete=models.PROTECT, related_name="transfers_out"
)
to_account = models.ForeignKey(
Account, on_delete=models.PROTECT, related_name="transfers_in"
)
# Las dos patas y la comisión opcional. SET_NULL para que si algo las borra
# por fuera el Transfer no arrastre, pero el flujo normal las borra en cascada
# desde el propio Transfer (ver delete()).
#
# blank=True porque un traspaso siempre acaba con sus dos patas, pero no las
# tiene en el instante en que se construye: sync() las crea a partir de él.
# Exigirlas en full_clean() describiría mal ese momento.
expense_leg = models.OneToOneField(
Expense,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="transfer_out",
)
income_leg = models.OneToOneField(
Income,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="transfer_in",
)
fee_amount = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True
)
fee_expense = models.OneToOneField(
Expense,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="transfer_fee",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return f"{self.date} - {self.amount} ({self.from_account} → {self.to_account})"
def clean(self):
if self.from_account_id and self.from_account_id == self.to_account_id:
raise ValidationError(
{"to_account": "El origen y el destino tienen que ser cuentas distintas."}
)
@transaction.atomic
def sync(self):
"""Guarda el traspaso y pone sus patas al día en la misma transacción.
Un solo método para crear y para editar: si no hay patas las crea, y si
las hay les propaga importe, fecha y cuentas (la categoría no, que el
usuario puede haberla recolocado). La comisión aparece o desaparece
según fee_amount.
Ojo con la comisión: va con is_transfer=False a propósito. Las dos patas
del movimiento solo mueven dinero entre cuentas propias y no son gasto
real, pero la comisión sale del patrimonio de verdad, así que es un gasto
normal y tiene que contar en el dashboard y en los presupuestos.
"""
if self.expense_leg_id:
leg = self.expense_leg
leg.date = self.date
leg.amount = self.amount
leg.account = self.from_account
leg.description = self._expense_description()
leg.save()
else:
self.expense_leg = Expense.objects.create(
owner=self.owner,
account=self.from_account,
category=_system_category(
self.owner, self.TRANSFER_CATEGORY_SLUG, "Traspaso"
),
amount=self.amount,
date=self.date,
description=self._expense_description(),
is_transfer=True,
)
if self.income_leg_id:
leg = self.income_leg
leg.date = self.date
leg.amount = self.amount
leg.account = self.to_account
leg.name = self._income_name()
leg.save()
else:
self.income_leg = Income.objects.create(
owner=self.owner,
account=self.to_account,
name=self._income_name(),
amount=self.amount,
date=self.date,
is_transfer=True,
)
# La comisión que sobra se borra después de guardar el Transfer, para no
# dejar ni un instante el FK apuntando a una fila que ya no existe.
stale_fee = None
if self.fee_amount:
if self.fee_expense_id:
fee = self.fee_expense
fee.date = self.date
fee.amount = self.fee_amount
fee.account = self.from_account
fee.save()
else:
self.fee_expense = Expense.objects.create(
owner=self.owner,
account=self.from_account,
category=_system_category(
self.owner, self.FEE_CATEGORY_SLUG, "Comisión"
),
amount=self.fee_amount,
date=self.date,
description=self._fee_description(),
is_transfer=False,
)
elif self.fee_expense_id:
stale_fee = self.fee_expense
self.fee_expense = None
self.save()
if stale_fee is not None:
stale_fee.delete()
return self
@transaction.atomic
def delete(self, *args, **kwargs):
"""Borra el traspaso y sus tres patas.
Las patas son SET_NULL, así que borrarlas no arrastraría el Transfer;
el arrastre va en este sentido, y va aquí y no en la vista para que
valga también desde el shell o los tests.
"""
legs = [self.expense_leg, self.income_leg, self.fee_expense]
result = super().delete(*args, **kwargs)
for leg in legs:
if leg is not None:
leg.delete()
return result
def _expense_description(self):
return f"Traspaso a {self.to_account.name}"
def _income_name(self):
return f"Traspaso desde {self.from_account.name}"
def _fee_description(self):
return f"Comisión del traspaso a {self.to_account.name}"
class FuelEntry(models.Model): class FuelEntry(models.Model):
expense = models.OneToOneField( expense = models.OneToOneField(

View File

@ -0,0 +1,51 @@
// Al marcar la casilla de traspaso en el formulario de gasto o de ingreso, salta
// al formulario de traspaso llevándose fecha, importe y cuenta. El movimiento no
// se guarda: un traspaso son dos patas y las crea Transfer.sync().
//
// Esto es solo un atajo para no obligar a enviar el formulario: si el JS no corre,
// la vista hace la misma redirección al recibir el POST, así que el
// comportamiento no depende de este fichero.
document.addEventListener('DOMContentLoaded', function () {
var form = document.querySelector('[data-transfer-url]');
if (!form) {
return;
}
var checkbox = form.querySelector('#id_is_transfer');
if (!checkbox) {
return; // editando no se pinta la casilla
}
// Qué cuenta es la del formulario en el traspaso: origen si es un gasto,
// destino si es un ingreso. Lo decide la plantilla, no este fichero.
var accountParam = form.dataset.transferAccountParam;
checkbox.addEventListener('change', function () {
if (!checkbox.checked) {
return;
}
var params = new URLSearchParams();
['date', 'amount'].forEach(function (name) {
var field = form.querySelector('[name="' + name + '"]');
if (field && field.value) {
params.set(name, field.value);
}
});
var account = form.querySelector('[name="account"]');
if (account && account.value) {
params.set(accountParam, account.value);
}
var query = params.toString();
window.location.href = query
? form.dataset.transferUrl + '?' + query
: form.dataset.transferUrl;
});
});

View File

@ -7,7 +7,12 @@
{% block delete_question %}¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?{% endblock %} {% block delete_question %}¿Seguro que quieres eliminar la categoría <strong>{{ category.name }}</strong>?{% endblock %}
{% block delete_warnings %} {% block delete_warnings %}
{% if expense_count %} {% if category.is_system %}
<p class="form-errors">
Esta categoría la usa la aplicación para clasificar los traspasos y sus
comisiones, así que no se puede eliminar.
</p>
{% elif expense_count %}
<p class="form-errors"> <p class="form-errors">
Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }} Esta categoría tiene {{ expense_count }} gasto{{ expense_count|pluralize }} asociado{{ expense_count|pluralize }}
y no se puede eliminar. Reasigna esos gastos a otra categoría primero. y no se puede eliminar. Reasigna esos gastos a otra categoría primero.

View File

@ -30,11 +30,18 @@
<tbody> <tbody>
{% for row in category_rows %} {% for row in category_rows %}
<tr> <tr>
<td class="cat-depth-{{ row.depth }}">{{ row.category.name }}</td> <td class="cat-depth-{{ row.depth }}">
{{ row.category.name }}
{% if row.category.is_system %}
<span class="badge badge-neutral">Del sistema</span>
{% endif %}
</td>
<td>{{ row.category.expense_count }}</td> <td>{{ row.category.expense_count }}</td>
<td class="table-actions"> <td class="table-actions">
<a href="{% url 'category_edit' row.category.id %}">Editar</a> <a href="{% url 'category_edit' row.category.id %}">Editar</a>
{% if not row.category.is_system %}
<a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a> <a href="{% url 'category_delete' row.category.id %}" class="danger">Eliminar</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@ -33,6 +33,7 @@
<a href="{% url 'dashboard' %}" class="nav-item {% if active_menu == 'dashboard' %}active{% endif %} ">Dashboard</a> <a href="{% url 'dashboard' %}" class="nav-item {% if active_menu == 'dashboard' %}active{% endif %} ">Dashboard</a>
<a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a> <a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a>
<a href="{% url 'income_list' %}" class="nav-item {% if active_menu == 'incomes' %}active{% endif %} ">Ingresos</a> <a href="{% url 'income_list' %}" class="nav-item {% if active_menu == 'incomes' %}active{% endif %} ">Ingresos</a>
<a href="{% url 'transfer_list' %}" class="nav-item {% if active_menu == 'transfers' %}active{% endif %} ">Traspasos</a>
<a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a> <a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a>
<a href="{% url 'account_list' %}" class="nav-item {% if active_menu == 'accounts' %}active{% endif %} ">Cuentas</a> <a href="{% url 'account_list' %}" class="nav-item {% if active_menu == 'accounts' %}active{% endif %} ">Cuentas</a>
<div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}" id="settingsDropdown"> <div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}" id="settingsDropdown">

View File

@ -1,4 +1,5 @@
{% extends "expenses/base.html" %} {% extends "expenses/base.html" %}
{% load static %}
{% block title %} {% block title %}
{% if form.instance.pk %} {% if form.instance.pk %}
@ -17,7 +18,9 @@
{% endif %} {% endif %}
</h1> </h1>
<form method="post" class="app-form"> <form method="post" class="app-form"
data-transfer-url="{% url 'transfer_create' %}"
data-transfer-account-param="from_account">
{% csrf_token %} {% csrf_token %}
{% include "expenses/_form_fields.html" %} {% include "expenses/_form_fields.html" %}
@ -34,3 +37,7 @@
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{% static 'expenses/js/transfer_redirect.js' %}" defer></script>
{% endblock %}

View File

@ -145,7 +145,10 @@
{% endfor %} {% endfor %}
</td> </td>
<td class="table-actions"> <td class="table-actions">
{% if expense.fuel_data %} {% if expense.transfer %}
{# Pata de un traspaso: se edita y se borra desde el traspaso. #}
<a href="{% url 'transfer_edit' expense.transfer.id %}">Editar traspaso</a>
{% elif expense.fuel_data %}
<a href="{% url 'fuel_edit' expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a> <a href="{% url 'fuel_edit' expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a>
<a href="{% url 'fuel_delete' expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a> <a href="{% url 'fuel_delete' expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a>
{% else %} {% else %}

View File

@ -1,4 +1,5 @@
{% extends "expenses/base.html" %} {% extends "expenses/base.html" %}
{% load static %}
{% block title %} {% block title %}
{% if form.instance.pk %} {% if form.instance.pk %}
Editar ingreso Editar ingreso
@ -16,7 +17,9 @@
{% endif %} {% endif %}
</h1> </h1>
<form method="post" class="app-form"> <form method="post" class="app-form"
data-transfer-url="{% url 'transfer_create' %}"
data-transfer-account-param="to_account">
{% csrf_token %} {% csrf_token %}
{% include "expenses/_form_fields.html" %} {% include "expenses/_form_fields.html" %}
<div class="form-actions"> <div class="form-actions">
@ -31,3 +34,7 @@
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{% static 'expenses/js/transfer_redirect.js' %}" defer></script>
{% endblock %}

View File

@ -93,8 +93,13 @@
<td>{{ income.amount|floatformat:2 }}</td> <td>{{ income.amount|floatformat:2 }}</td>
<td>{{ income.date }}</td> <td>{{ income.date }}</td>
<td class="table-actions"> <td class="table-actions">
{% if income.transfer %}
{# Pata de un traspaso: se edita y se borra desde el traspaso. #}
<a href="{% url 'transfer_edit' income.transfer.id %}">Editar traspaso</a>
{% else %}
<a href="{% url 'income_edit' income.id %}">Editar</a> <a href="{% url 'income_edit' income.id %}">Editar</a>
<a href="{% url 'income_delete' income.id %}" class="danger">Eliminar</a> <a href="{% url 'income_delete' income.id %}" class="danger">Eliminar</a>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}

View File

@ -0,0 +1,23 @@
{% extends "expenses/_confirm_delete.html" %}
{% block title %}Traspasos{% endblock %}
{% block delete_heading %}Eliminar traspaso{% endblock %}
{% block delete_question %}
¿Seguro que quieres eliminar el traspaso de
<strong>{{ transfer.amount|floatformat:2 }}€</strong>
de <strong>{{ transfer.from_account.name }}</strong>
a <strong>{{ transfer.to_account.name }}</strong>
del {{ transfer.date }}?
{% endblock %}
{% block delete_warnings %}
<p class="form-errors">
Se eliminarán también el gasto de la cuenta de origen y el ingreso de la de
destino{% if transfer.fee_amount %}, además del gasto de la comisión{% endif %}.
Los saldos de las dos cuentas volverán a como estaban.
</p>
{% endblock %}
{% block cancel_url %}{% url 'transfer_list' %}{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "expenses/base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{% if form.instance.pk %}
<p>
Al guardar se actualizan los dos movimientos del traspaso y su comisión.
No hace falta tocarlos por separado en Gastos ni en Ingresos.
</p>
{% endif %}
<form method="post" class="app-form">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if form.instance.pk %}
Guardar traspaso
{% else %}
Crear traspaso
{% endif %}
</button>
<a class="btn btn-secondary" href="{% url 'transfer_list' %}">Volver</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends "expenses/base.html" %}
{% block title %}
Traspasos
{% endblock %}
{% block content %}
<h1>Mis traspasos</h1>
<p>
Un traspaso mueve dinero entre dos cuentas propias: no cuenta como gasto ni
como ingreso en los análisis, pero sí cambia el saldo de las dos cuentas.
La comisión, si la hay, sí es un gasto real.
</p>
<div class="section-actions">
<a class="btn btn-primary" href="{% url 'transfer_create' %}">➕ Nuevo traspaso</a>
</div>
<br>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Origen</th>
<th>Destino</th>
<th>Importe</th>
<th>Comisión</th>
<th></th>
</tr>
</thead>
<tbody>
{% for transfer in transfers %}
<tr>
<td>{{ transfer.date }}</td>
<td>{{ transfer.from_account.name }}</td>
<td>{{ transfer.to_account.name }}</td>
<td>{{ transfer.amount|floatformat:2 }}€</td>
<td>
{% if transfer.fee_amount %}
{{ transfer.fee_amount|floatformat:2 }}€
{% else %}
<span>-</span>
{% endif %}
</td>
<td class="table-actions">
<a href="{% url 'transfer_edit' transfer.id %}">Editar</a>
<a href="{% url 'transfer_delete' transfer.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="empty-state">
<p>No hay traspasos</p>
<a href="{% url 'transfer_create' %}">Añade el primero</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -101,6 +101,26 @@ def test_category_delete_without_expenses_succeeds(auth_client, user):
assert not Category.objects.filter(pk=cat.pk).exists() assert not Category.objects.filter(pk=cat.pk).exists()
def test_system_category_cannot_be_deleted(auth_client, user):
"""Las categorías de traspaso y comisión las pone la app, no el usuario."""
cat = Category.objects.create(name="Traspaso", owner=user, is_system=True)
response = auth_client.post(reverse('category_delete', args=[cat.pk]))
assert response.status_code == 302
assert Category.objects.filter(pk=cat.pk).exists()
messages = [str(m) for m in get_messages(response.wsgi_request)]
assert any("traspasos" in m for m in messages)
def test_system_category_has_no_delete_link_in_the_list(auth_client, user):
cat = Category.objects.create(name="Traspaso", owner=user, is_system=True)
response = auth_client.get(reverse('category_list'))
assert reverse('category_delete', args=[cat.pk]) not in response.content.decode()
def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user): def test_category_list_view_returns_tree_ordered_with_depth(auth_client, user):
root_a = Category.objects.create(name="A raiz", owner=user) root_a = Category.objects.create(name="A raiz", owner=user)
Category.objects.create(name="B raiz", owner=user) Category.objects.create(name="B raiz", owner=user)

View File

@ -0,0 +1,977 @@
"""El modelo Transfer: orquesta las dos patas de un traspaso.
Transfer no sustituye al Expense de salida y al Income de entrada: los crea, los
vincula y los mantiene sincronizados. De ahí que aquí no se compruebe ningún
método de saldo nuevo, sino que los de siempre siguen dando el número correcto
porque las patas siguen siendo un gasto y un ingreso reales.
El punto más delicado, y el que más tests tiene: la comisión va con
is_transfer=False, porque es dinero que sale del patrimonio de verdad y sí tiene
que contar en los análisis.
"""
import importlib
from datetime import date
from decimal import Decimal
from urllib.parse import parse_qs, urlparse
import pytest
from django.apps import apps as django_apps
from django.core.exceptions import ValidationError
from django.urls import reverse
from expenses.models import Account, Category, Expense, Income, Transfer
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_transfer(user, from_account, to_account, amount="400", on=None, fee=None):
transfer = Transfer(
owner=user,
date=on or date(2024, 5, 11),
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
# --------------------------------------------------------------------------
# Creación: las dos patas
# --------------------------------------------------------------------------
def test_creating_a_transfer_builds_and_links_both_legs(user, account, savings):
transfer = make_transfer(user, account, savings)
assert transfer.expense_leg is not None
assert transfer.income_leg is not None
assert transfer.expense_leg.account == account
assert transfer.expense_leg.amount == Decimal("400")
assert transfer.expense_leg.date == transfer.date
assert transfer.expense_leg.owner == user
assert transfer.expense_leg.is_transfer is True
assert transfer.income_leg.account == savings
assert transfer.income_leg.amount == Decimal("400")
assert transfer.income_leg.date == transfer.date
assert transfer.income_leg.owner == user
assert transfer.income_leg.is_transfer is True
# El vínculo se ve desde las dos puntas.
assert transfer.expense_leg.transfer == transfer
assert transfer.income_leg.transfer == transfer
def test_legs_use_the_system_transfer_category(user, account, savings):
transfer = make_transfer(user, account, savings)
leg_category = transfer.expense_leg.category
assert leg_category.slug == "traspaso"
assert leg_category.is_system is True
assert leg_category.owner == user
def test_two_transfers_share_the_same_system_category(user, account, savings):
first = make_transfer(user, account, savings)
second = make_transfer(user, account, savings, on=date(2024, 6, 11))
assert first.expense_leg.category_id == second.expense_leg.category_id
assert Category.objects.filter(owner=user, slug="traspaso").count() == 1
def test_both_balances_move(user, account, savings):
account.initial_balance = Decimal("1000")
account.save()
make_transfer(user, account, savings)
assert account.current_balance() == Decimal("600")
assert savings.current_balance() == Decimal("400")
def test_transfer_does_not_change_the_total_patrimony(user, account, savings):
account.initial_balance = Decimal("1000")
account.save()
make_transfer(user, account, savings)
total = account.current_balance() + savings.current_balance()
assert total == Decimal("1000")
# --------------------------------------------------------------------------
# La comisión: un gasto real, sin is_transfer
# --------------------------------------------------------------------------
def test_fee_creates_a_third_expense_that_is_not_a_transfer(user, account, savings):
transfer = make_transfer(user, account, savings, fee="3")
assert transfer.fee_expense is not None
assert transfer.fee_expense.amount == Decimal("3")
assert transfer.fee_expense.account == account
assert transfer.fee_expense.date == transfer.date
# El punto donde es más fácil equivocarse: la comisión NO es un traspaso.
assert transfer.fee_expense.is_transfer is False
assert transfer.fee_expense.category.slug == "comision"
assert transfer.fee_expense.category.is_system is True
def test_fee_is_charged_to_the_origin_account(user, account, savings):
account.initial_balance = Decimal("1000")
account.save()
make_transfer(user, account, savings, fee="3")
# El origen paga el traspaso y la comisión; el destino solo recibe el traspaso.
assert account.current_balance() == Decimal("597")
assert savings.current_balance() == Decimal("400")
def test_dashboard_counts_the_fee_but_not_the_legs(auth_client, user, account, savings):
make_transfer(user, account, savings, fee="3", on=date(2024, 5, 11))
response = auth_client.get(reverse("dashboard"), {"year": 2024})
assert response.context["kpi_total"] == Decimal("3")
assert response.context["kpi_count"] == 1
rows = {row["category__name"]: row["total"] for row in response.context["by_category"]}
assert rows == {"Comisión": Decimal("3")}
def test_without_fee_the_dashboard_sees_nothing(auth_client, user, account, savings):
make_transfer(user, account, savings, on=date(2024, 5, 11))
response = auth_client.get(reverse("dashboard"), {"year": 2024})
assert response.context["kpi_total"] == Decimal("0")
assert response.context["kpi_count"] == 0
# --------------------------------------------------------------------------
# Edición: sincronizar las patas
# --------------------------------------------------------------------------
def test_editing_the_amount_updates_both_legs(user, account, savings):
transfer = make_transfer(user, account, savings)
transfer.amount = Decimal("250")
transfer.sync()
transfer.expense_leg.refresh_from_db()
transfer.income_leg.refresh_from_db()
assert transfer.expense_leg.amount == Decimal("250")
assert transfer.income_leg.amount == Decimal("250")
assert account.current_balance() == Decimal("-250")
assert savings.current_balance() == Decimal("250")
def test_editing_the_date_updates_both_legs(user, account, savings):
transfer = make_transfer(user, account, savings)
transfer.date = date(2024, 8, 1)
transfer.sync()
transfer.expense_leg.refresh_from_db()
transfer.income_leg.refresh_from_db()
assert transfer.expense_leg.date == date(2024, 8, 1)
assert transfer.income_leg.date == date(2024, 8, 1)
def test_editing_the_accounts_moves_the_legs(user, account, savings):
transfer = make_transfer(user, account, savings)
third = Account.objects.create(
owner=user, name="Bolsillo", initial_balance=Decimal("0"), active=True
)
transfer.from_account = savings
transfer.to_account = third
transfer.sync()
transfer.expense_leg.refresh_from_db()
transfer.income_leg.refresh_from_db()
assert transfer.expense_leg.account == savings
assert transfer.income_leg.account == third
assert account.current_balance() == Decimal("0")
def test_editing_does_not_create_new_legs(user, account, savings):
transfer = make_transfer(user, account, savings)
expense_id, income_id = transfer.expense_leg_id, transfer.income_leg_id
transfer.amount = Decimal("250")
transfer.sync()
assert transfer.expense_leg_id == expense_id
assert transfer.income_leg_id == income_id
assert Expense.objects.count() == 1
assert Income.objects.count() == 1
def test_adding_a_fee_on_edit_creates_it(user, account, savings):
transfer = make_transfer(user, account, savings)
assert transfer.fee_expense is None
transfer.fee_amount = Decimal("3")
transfer.sync()
assert transfer.fee_expense is not None
assert transfer.fee_expense.is_transfer is False
assert Expense.objects.count() == 2
def test_removing_the_fee_on_edit_deletes_it(user, account, savings):
transfer = make_transfer(user, account, savings, fee="3")
fee_id = transfer.fee_expense_id
transfer.fee_amount = None
transfer.sync()
assert transfer.fee_expense is None
assert not Expense.objects.filter(pk=fee_id).exists()
transfer.refresh_from_db()
assert transfer.fee_expense_id is None
def test_editing_the_fee_amount_updates_the_expense(user, account, savings):
transfer = make_transfer(user, account, savings, fee="3")
transfer.fee_amount = Decimal("5")
transfer.sync()
transfer.fee_expense.refresh_from_db()
assert transfer.fee_expense.amount == Decimal("5")
assert Expense.objects.count() == 2
# --------------------------------------------------------------------------
# Borrado
# --------------------------------------------------------------------------
def test_deleting_the_transfer_deletes_both_legs(user, account, savings):
transfer = make_transfer(user, account, savings)
transfer.delete()
assert Expense.objects.count() == 0
assert Income.objects.count() == 0
assert Transfer.objects.count() == 0
def test_deleting_the_transfer_deletes_the_fee_too(user, account, savings):
transfer = make_transfer(user, account, savings, fee="3")
transfer.delete()
assert Expense.objects.count() == 0
assert Income.objects.count() == 0
assert Transfer.objects.count() == 0
def test_deleting_the_transfer_restores_both_balances(user, account, savings):
account.initial_balance = Decimal("1000")
account.save()
transfer = make_transfer(user, account, savings, fee="3")
transfer.delete()
assert account.current_balance() == Decimal("1000")
assert savings.current_balance() == Decimal("0")
# --------------------------------------------------------------------------
# Validación y atomicidad
# --------------------------------------------------------------------------
def test_same_account_on_both_sides_is_rejected(user, account):
transfer = Transfer(
owner=user,
date=date(2024, 5, 11),
amount=Decimal("400"),
from_account=account,
to_account=account,
)
with pytest.raises(ValidationError) as error:
transfer.full_clean()
assert "to_account" in error.value.message_dict
def test_different_accounts_pass_validation(user, account, savings):
transfer = Transfer(
owner=user,
date=date(2024, 5, 11),
amount=Decimal("400"),
from_account=account,
to_account=savings,
)
# A pelo y sin excluir nada: las patas son blank=True porque aún no existen.
transfer.full_clean() # no levanta
def test_a_failed_leg_leaves_nothing_behind(user, account, savings, monkeypatch):
"""Atomicidad: si la segunda pata falla, la primera no queda suelta."""
def boom(self):
raise RuntimeError("fallo al construir la pata de entrada")
monkeypatch.setattr(Transfer, "_income_name", boom)
transfer = Transfer(
owner=user,
date=date(2024, 5, 11),
amount=Decimal("400"),
from_account=account,
to_account=savings,
)
with pytest.raises(RuntimeError):
transfer.sync()
assert Expense.objects.count() == 0
assert Income.objects.count() == 0
assert Transfer.objects.count() == 0
# --------------------------------------------------------------------------
# Las patas no se editan por separado
# --------------------------------------------------------------------------
def test_expense_leg_cannot_be_edited_on_its_own(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings)
response = auth_client.get(
reverse("expense_edit", args=[transfer.expense_leg_id]), follow=True
)
assert response.redirect_chain[0][0] == reverse("transfer_edit", args=[transfer.pk])
def test_expense_leg_cannot_be_deleted_on_its_own(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings)
auth_client.post(reverse("expense_delete", args=[transfer.expense_leg_id]))
assert Expense.objects.filter(pk=transfer.expense_leg_id).exists()
def test_income_leg_cannot_be_deleted_on_its_own(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings)
auth_client.post(reverse("income_delete", args=[transfer.income_leg_id]))
assert Income.objects.filter(pk=transfer.income_leg_id).exists()
def test_the_fee_expense_cannot_be_edited_on_its_own(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings, fee="3")
response = auth_client.get(
reverse("expense_edit", args=[transfer.fee_expense_id]), follow=True
)
assert response.redirect_chain[0][0] == reverse("transfer_edit", args=[transfer.pk])
def test_a_normal_expense_is_still_editable(auth_client, user, account, category):
expense = Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
)
response = auth_client.get(reverse("expense_edit", args=[expense.pk]))
assert response.status_code == 200
assert expense.transfer is None
def test_the_listing_links_a_leg_to_its_transfer(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings)
response = auth_client.get(reverse("expense_list"), {"year": 2024})
html = response.content.decode()
assert reverse("transfer_edit", args=[transfer.pk]) in html
assert reverse("expense_delete", args=[transfer.expense_leg_id]) not in html
@pytest.mark.parametrize("listing", ["expense_list", "income_list"])
def test_no_template_comment_leaks_into_the_listing(
auth_client, user, account, savings, listing
):
"""Los {# #} de Django son de una sola línea: partidos en dos se imprimen."""
make_transfer(user, account, savings)
response = auth_client.get(reverse(listing), {"year": 2024})
assert "{#" not in response.content.decode()
# --------------------------------------------------------------------------
# Vistas CRUD
# --------------------------------------------------------------------------
def test_create_view_builds_the_three_legs(auth_client, user, account, savings):
response = auth_client.post(
reverse("transfer_create"),
{
"date": "2024-05-11",
"amount": "400",
"from_account": account.id,
"to_account": savings.id,
"fee_amount": "3",
},
)
assert response.status_code == 302
transfer = Transfer.objects.get(owner=user)
assert transfer.expense_leg.is_transfer is True
assert transfer.income_leg.is_transfer is True
assert transfer.fee_expense.is_transfer is False
def test_create_view_without_fee(auth_client, user, account, savings):
auth_client.post(
reverse("transfer_create"),
{
"date": "2024-05-11",
"amount": "400",
"from_account": account.id,
"to_account": savings.id,
"fee_amount": "",
},
)
transfer = Transfer.objects.get(owner=user)
assert transfer.fee_amount is None
assert transfer.fee_expense is None
assert Expense.objects.count() == 1
def test_create_view_rejects_the_same_account_twice(auth_client, user, account):
response = auth_client.post(
reverse("transfer_create"),
{
"date": "2024-05-11",
"amount": "400",
"from_account": account.id,
"to_account": account.id,
},
)
assert response.status_code == 200
assert Transfer.objects.count() == 0
assert "to_account" in response.context["form"].errors
def test_edit_view_syncs_the_legs(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings)
response = auth_client.post(
reverse("transfer_edit", args=[transfer.pk]),
{
"date": "2024-05-11",
"amount": "250",
"from_account": account.id,
"to_account": savings.id,
"fee_amount": "",
},
)
assert response.status_code == 302
transfer.expense_leg.refresh_from_db()
transfer.income_leg.refresh_from_db()
assert transfer.expense_leg.amount == Decimal("250")
assert transfer.income_leg.amount == Decimal("250")
def test_delete_view_removes_everything(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings, fee="3")
response = auth_client.post(reverse("transfer_delete", args=[transfer.pk]))
assert response.status_code == 302
assert Transfer.objects.count() == 0
assert Expense.objects.count() == 0
assert Income.objects.count() == 0
def test_delete_view_needs_post(auth_client, user, account, savings):
transfer = make_transfer(user, account, savings)
response = auth_client.get(reverse("transfer_delete", args=[transfer.pk]))
assert response.status_code == 200
assert Transfer.objects.count() == 1
def test_list_view_only_shows_your_own(auth_client, user, account, savings, django_user_model):
make_transfer(user, account, savings)
other = django_user_model.objects.create_user(username="otro", password="x")
other_account = Account.objects.create(
owner=other, name="Suya", initial_balance=Decimal("0"), active=True
)
other_savings = Account.objects.create(
owner=other, name="Suya 2", initial_balance=Decimal("0"), active=True
)
make_transfer(other, other_account, other_savings)
response = auth_client.get(reverse("transfer_list"))
assert [t.owner for t in response.context["transfers"]] == [user]
def test_form_only_offers_your_active_accounts(auth_client, user, account, savings):
Account.objects.create(
owner=user, name="Cerrada", initial_balance=Decimal("0"), active=False
)
response = auth_client.get(reverse("transfer_create"))
form = response.context["form"]
names = {acc.name for acc in form.fields["from_account"].queryset}
assert names == {account.name, savings.name}
assert names == {acc.name for acc in form.fields["to_account"].queryset}
def test_transfer_edit_view_is_scoped_by_owner(auth_client, django_user_model):
other = django_user_model.objects.create_user(username="otro", password="x")
other_account = Account.objects.create(
owner=other, name="Suya", initial_balance=Decimal("0"), active=True
)
other_savings = Account.objects.create(
owner=other, name="Suya 2", initial_balance=Decimal("0"), active=True
)
transfer = make_transfer(other, other_account, other_savings)
response = auth_client.get(reverse("transfer_edit", args=[transfer.pk]))
assert response.status_code == 404
# --------------------------------------------------------------------------
# El atajo de la casilla: del gasto o del ingreso al traspaso
# --------------------------------------------------------------------------
def _query(url):
return parse_qs(urlparse(url).query)
def test_the_expense_form_carries_its_data_to_the_transfer_form(
auth_client, user, account, category
):
"""La cuenta de un gasto entra como origen: el dinero sale de ahí."""
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 urlparse(response.url).path == reverse("transfer_create")
assert _query(response.url) == {
"date": ["2024-05-11"],
"amount": ["400"],
"from_account": [str(account.id)],
}
def test_the_income_form_carries_its_account_as_destination(auth_client, user, savings):
response = auth_client.post(
reverse("income_create"),
{
"account": savings.id,
"name": "Traspaso",
"amount": "400",
"date": "2024-05-11",
"is_transfer": "on",
},
)
assert _query(response.url) == {
"date": ["2024-05-11"],
"amount": ["400"],
"to_account": [str(savings.id)],
}
def test_a_half_filled_expense_still_reaches_the_transfer_form(
auth_client, user, account
):
"""Sin categoría el gasto no valida, pero el salto no depende de que valide:
se lleva lo que haya y lo que falte se queda en blanco."""
response = auth_client.post(
reverse("expense_create"),
{"date": "", "amount": "400", "account": account.id, "is_transfer": "on"},
)
assert response.status_code == 302
assert _query(response.url) == {
"amount": ["400"],
"from_account": [str(account.id)],
}
assert not Expense.objects.filter(owner=user).exists()
def test_the_transfer_form_prefills_from_the_url(auth_client, user, account, savings):
response = auth_client.get(
reverse("transfer_create"),
{
"date": "2024-05-11",
"amount": "400",
"from_account": account.id,
"to_account": savings.id,
},
)
assert response.context["form"].initial == {
"date": date(2024, 5, 11),
"amount": Decimal("400"),
"from_account": account.id,
"to_account": savings.id,
}
@pytest.mark.parametrize(
"params",
[
{"date": "11/05/2024"},
{"date": "2024-13-45"},
{"amount": "cuatrocientos"},
{"amount": "NaN"},
{"amount": "Infinity"},
{"from_account": "primera"},
{"to_account": "999999"},
],
)
def test_an_invalid_url_param_is_ignored_and_does_not_break(auth_client, user, params):
"""La URL la escribe cualquiera: lo que no se entienda sale en blanco."""
response = auth_client.get(reverse("transfer_create"), params)
assert response.status_code == 200
assert response.context["form"].initial == {}
def test_an_account_of_another_user_is_not_prefilled(
auth_client, user, django_user_model
):
intruder = django_user_model.objects.create_user(username="otro", password="x")
foreign = Account.objects.create(
owner=intruder, name="Ajena", initial_balance=Decimal("0"), active=True
)
response = auth_client.get(reverse("transfer_create"), {"from_account": foreign.id})
assert response.context["form"].initial == {}
def test_an_inactive_account_is_not_prefilled(auth_client, user):
"""Las cuentas desactivadas no están en el desplegable: preseleccionar una
dejaría el formulario con un valor que no se puede enviar."""
closed = Account.objects.create(
owner=user, name="Cerrada", initial_balance=Decimal("0"), active=False
)
response = auth_client.get(reverse("transfer_create"), {"to_account": closed.id})
assert response.context["form"].initial == {}
# --------------------------------------------------------------------------
# La casilla de traspaso no se ofrece al editar
# --------------------------------------------------------------------------
def test_the_checkbox_is_offered_when_creating_an_expense(auth_client, user):
response = auth_client.get(reverse("expense_create"))
assert "id_is_transfer" in response.content.decode()
def test_the_checkbox_is_offered_when_creating_an_income(auth_client, user):
response = auth_client.get(reverse("income_create"))
assert "id_is_transfer" in response.content.decode()
@pytest.mark.parametrize(
"view_name,account_param",
[("expense_create", "from_account"), ("income_create", "to_account")],
)
def test_the_create_form_tells_the_javascript_where_to_jump(
auth_client, user, view_name, account_param
):
"""El JS no sabe nada de traspasos: el destino y el papel de la cuenta se los
da la plantilla en data-*. Renombrarlos rompería el salto inmediato sin que
nada se queje, porque el POST seguiría redirigiendo igual.
"""
response = auth_client.get(reverse(view_name))
html = response.content.decode()
assert f'data-transfer-url="{reverse("transfer_create")}"' in html
assert f'data-transfer-account-param="{account_param}"' in html
assert "transfer_redirect.js" in html
def test_the_checkbox_is_hidden_when_editing_an_expense(
auth_client, user, account, category
):
"""Editar un gasto no es convertirlo en traspaso: la casilla no pinta nada."""
expense = Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
)
response = auth_client.get(reverse("expense_edit", args=[expense.pk]))
assert "id_is_transfer" not in response.content.decode()
def test_the_checkbox_is_hidden_when_editing_an_income(auth_client, user, account):
income = Income.objects.create(
owner=user,
account=account,
name="Nómina",
amount=Decimal("10"),
date=date(2024, 5, 11),
)
response = auth_client.get(reverse("income_edit", args=[income.pk]))
assert "id_is_transfer" not in response.content.decode()
def test_posting_is_transfer_while_editing_has_no_effect(
auth_client, user, account, category
):
"""El campo no está en el formulario, así que mandarlo a mano tampoco cuela."""
expense = Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("10"),
date=date(2024, 5, 11),
)
auth_client.post(
reverse("expense_edit", args=[expense.pk]),
{
"date": "2024-05-11",
"amount": "10",
"description": "",
"category": category.id,
"account": account.id,
"is_transfer": "on",
},
)
expense.refresh_from_db()
assert expense.is_transfer is False
def test_a_leg_shows_its_transfer_link_and_no_checkbox(
auth_client, user, account, savings
):
"""Una pata sigue mandando al traspaso: ni casilla, ni edición suelta."""
transfer = make_transfer(user, account, savings)
listing = auth_client.get(reverse("expense_list"), {"year": 2024})
html = listing.content.decode()
assert reverse("transfer_edit", args=[transfer.pk]) in html
assert "id_is_transfer" not in html
edit = auth_client.get(
reverse("expense_edit", args=[transfer.expense_leg_id]), follow=True
)
assert edit.redirect_chain[0][0] == reverse("transfer_edit", args=[transfer.pk])
assert "id_is_transfer" not in edit.content.decode()
# --------------------------------------------------------------------------
# Migración de datos: emparejar los traspasos históricos
# --------------------------------------------------------------------------
def link_legacy_transfers():
"""Lanza la función de la migración 0015 contra los modelos reales."""
migration = importlib.import_module("expenses.migrations.0015_link_legacy_transfers")
migration.link_legacy_transfers(django_apps, None)
def legacy_pair(user, from_account, to_account, category, amount, on):
expense = Expense.objects.create(
owner=user,
account=from_account,
category=category,
amount=Decimal(amount),
date=on,
is_transfer=True,
)
income = Income.objects.create(
owner=user,
account=to_account,
name="Traspaso",
amount=Decimal(amount),
date=on,
is_transfer=True,
)
return expense, income
def test_migration_pairs_an_unambiguous_legacy_transfer(
user, account, savings, category
):
expense, income = legacy_pair(
user, account, savings, category, "200", date(2024, 5, 11)
)
link_legacy_transfers()
transfer = Transfer.objects.get()
assert transfer.expense_leg_id == expense.pk
assert transfer.income_leg_id == income.pk
assert transfer.from_account_id == account.pk
assert transfer.to_account_id == savings.pk
assert transfer.amount == Decimal("200")
assert transfer.date == date(2024, 5, 11)
assert transfer.fee_amount is None
def test_migration_leaves_ambiguous_candidates_alone(user, account, savings, category):
# Dos gastos y dos ingresos con el mismo importe y fecha: no hay forma de
# saber cuál va con cuál, así que no se toca ninguno.
legacy_pair(user, account, savings, category, "200", date(2024, 5, 11))
legacy_pair(user, account, savings, category, "200", date(2024, 5, 11))
link_legacy_transfers()
assert Transfer.objects.count() == 0
assert Expense.objects.filter(is_transfer=True).count() == 2
assert Income.objects.filter(is_transfer=True).count() == 2
def test_migration_leaves_orphans_alone(user, account, category):
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("200"),
date=date(2024, 5, 11),
is_transfer=True,
)
link_legacy_transfers()
assert Transfer.objects.count() == 0
assert Expense.objects.filter(is_transfer=True).count() == 1
def test_migration_ignores_normal_movements(user, account, savings, category):
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("200"),
date=date(2024, 5, 11),
)
Income.objects.create(
owner=user,
account=savings,
name="Nómina",
amount=Decimal("200"),
date=date(2024, 5, 11),
)
link_legacy_transfers()
assert Transfer.objects.count() == 0
def test_migration_skips_a_pair_on_the_same_account(user, account, category):
legacy_pair(user, account, account, category, "200", date(2024, 5, 11))
link_legacy_transfers()
assert Transfer.objects.count() == 0
def test_migration_does_not_cross_owners(user, account, category, django_user_model):
other = django_user_model.objects.create_user(username="otro", password="x")
other_account = Account.objects.create(
owner=other, name="Suya", initial_balance=Decimal("0"), active=True
)
Expense.objects.create(
owner=user,
account=account,
category=category,
amount=Decimal("200"),
date=date(2024, 5, 11),
is_transfer=True,
)
Income.objects.create(
owner=other,
account=other_account,
name="Traspaso",
amount=Decimal("200"),
date=date(2024, 5, 11),
is_transfer=True,
)
link_legacy_transfers()
assert Transfer.objects.count() == 0
def test_migration_is_idempotent(user, account, savings, category):
legacy_pair(user, account, savings, category, "200", date(2024, 5, 11))
link_legacy_transfers()
link_legacy_transfers()
assert Transfer.objects.count() == 1
def test_migration_does_not_touch_legs_of_an_existing_transfer(user, account, savings):
make_transfer(user, account, savings)
link_legacy_transfers()
assert Transfer.objects.count() == 1

View File

@ -203,7 +203,12 @@ def test_account_list_balance_counts_the_transfer(auth_client, user, account, ca
# Formularios # Formularios
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def test_expense_form_can_mark_a_transfer(auth_client, user, account, category): # Marcar la casilla ya no guarda un movimiento marcado: reconduce al formulario de
# traspaso, que crea las dos patas. El detalle está en test_transfer_model.py.
def test_marking_the_expense_form_as_transfer_creates_nothing(
auth_client, user, account, category
):
response = auth_client.post( response = auth_client.post(
reverse("expense_create"), reverse("expense_create"),
{ {
@ -217,10 +222,13 @@ def test_expense_form_can_mark_a_transfer(auth_client, user, account, category):
) )
assert response.status_code == 302 assert response.status_code == 302
assert Expense.objects.get(owner=user).is_transfer is True assert reverse("transfer_create") in response.url
assert not Expense.objects.filter(owner=user).exists()
def test_income_form_can_mark_a_transfer(auth_client, user, account): def test_marking_the_income_form_as_transfer_creates_nothing(
auth_client, user, account
):
response = auth_client.post( response = auth_client.post(
reverse("income_create"), reverse("income_create"),
{ {
@ -233,7 +241,8 @@ def test_income_form_can_mark_a_transfer(auth_client, user, account):
) )
assert response.status_code == 302 assert response.status_code == 302
assert Income.objects.get(owner=user).is_transfer is True assert reverse("transfer_create") in response.url
assert not Income.objects.filter(owner=user).exists()
def test_expense_defaults_to_not_a_transfer(user, account, category): def test_expense_defaults_to_not_a_transfer(user, account, category):

View File

@ -25,6 +25,10 @@ urlpatterns = [
path('fuel/create/', views.fuel_create, name='fuel_create'), path('fuel/create/', views.fuel_create, name='fuel_create'),
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'), path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
path('fuel/<int:pk>/delete/', views.fuel_delete, name='fuel_delete'), path('fuel/<int:pk>/delete/', views.fuel_delete, name='fuel_delete'),
path('transfers/', views.transfer_list, name='transfer_list'),
path('transfers/new/', views.transfer_create, name='transfer_create'),
path('transfers/<int:pk>/edit/', views.transfer_edit, name='transfer_edit'),
path('transfers/<int:pk>/delete/', views.transfer_delete, name='transfer_delete'),
path('categories/', views.category_list, name='category_list'), path('categories/', views.category_list, name='category_list'),
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'), path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'), path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),

View File

@ -1,9 +1,19 @@
import logging import logging
import calendar import calendar
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from urllib.parse import urlencode
from decimal import Decimal from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal from .models import (
Account,
Category,
Expense,
FuelEntry,
Tag,
Income,
Goal,
Transfer,
)
from .forms import ( from .forms import (
ExpenseForm, ExpenseForm,
IncomeForm, IncomeForm,
@ -12,6 +22,7 @@ from .forms import (
FuelEntryForm, FuelEntryForm,
CategoryForm, CategoryForm,
GoalForm, GoalForm,
TransferForm,
) )
from django.core.paginator import Paginator from django.core.paginator import Paginator
@ -27,6 +38,7 @@ from django.db.models.functions import (
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.shortcuts import get_object_or_404, render, redirect from django.shortcuts import get_object_or_404, render, redirect
from django.urls import reverse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -37,11 +49,6 @@ def _get_int(value):
return None 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): def _get_date(value):
"""Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida.""" """Parsea una fecha AAAA-MM-DD de la URL. None si falta o no es válida."""
try: try:
@ -50,6 +57,25 @@ def _get_date(value):
return None return None
def _get_decimal(value):
"""Parsea un importe de la URL. None si falta o no es un número usable.
Se descartan NaN e Infinity aparte: Decimal los acepta pero luego revientan
en cuanto se comparan o se guardan.
"""
try:
number = Decimal(value)
except (TypeError, ValueError, ArithmeticError):
return None
return number if number.is_finite() else 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 _resolve_date_range(request): def _resolve_date_range(request):
"""Lee el rango de fechas libre de la URL. """Lee el rango de fechas libre de la URL.
@ -128,6 +154,88 @@ def _analysis_expenses(user):
return Expense.objects.filter(owner=user).exclude(is_transfer=True) return Expense.objects.filter(owner=user).exclude(is_transfer=True)
def _reject_transfer_leg(request, movement):
"""Corta la edición o el borrado suelto de una pata de traspaso.
Devuelve un redirect a la edición del traspaso, o None si el movimiento es
normal y se puede tocar. Las patas las mantiene cuadradas Transfer.sync(),
así que tocar una por separado descuadraría la otra sin que nada lo avise:
es justo el problema que el modelo Transfer viene a resolver.
Los listados ya no enseñan esos enlaces, pero la URL sigue ahí a mano.
"""
transfer = movement.transfer
if transfer is None:
return None
messages.error(
request,
"Este movimiento forma parte de un traspaso. Edítalo o bórralo desde el "
"traspaso para que las dos cuentas sigan cuadradas.",
)
return redirect("transfer_edit", pk=transfer.pk)
def _transfer_create_url(data, account_param):
"""URL de "nuevo traspaso" arrastrando lo que ya se hubiera escrito.
`data` es el POST de un formulario de gasto o de ingreso y `account_param` el
papel que juega su cuenta en el traspaso: "from_account" si viene de un gasto
(el dinero sale de ahí) y "to_account" si viene de un ingreso.
Lo que no se pueda interpretar se deja fuera: el formulario de traspaso saldrá
con ese campo vacío, que es mejor que un error por un dato a medio escribir.
"""
params = {}
if (date_value := _get_date(data.get("date"))) is not None:
params["date"] = date_value.isoformat()
if (amount := _get_decimal(data.get("amount"))) is not None:
params["amount"] = str(amount)
if (account_id := _get_int(data.get("account"))) is not None:
params[account_param] = account_id
url = reverse("transfer_create")
return f"{url}?{urlencode(params)}" if params else url
def _transfer_initial(request):
"""Datos de arranque del formulario de traspaso leídos de la URL.
Los manda el formulario de gasto o de ingreso cuando el usuario marca la
casilla de traspaso. Nada de esto es de fiar (es la URL): lo que no se pueda
interpretar se ignora y el campo sale vacío.
"""
initial = {}
if (date_value := _get_date(request.GET.get("date"))) is not None:
initial["date"] = date_value
if (amount := _get_decimal(request.GET.get("amount"))) is not None:
initial["amount"] = amount
# Las cuentas se comprueban contra las del usuario: un id ajeno o de una
# cuenta desactivada no se preselecciona aunque venga en la URL.
wanted = {
field: account_id
for field in ("from_account", "to_account")
if (account_id := _get_int(request.GET.get(field))) is not None
}
if wanted:
own = set(
Account.objects.filter(
owner=request.user, active=True, pk__in=wanted.values()
).values_list("pk", flat=True)
)
initial.update({f: pk for f, pk in wanted.items() if pk in own})
return initial
def _account_balances(accounts): def _account_balances(accounts):
"""Calcula el saldo de cada cuenta con dos queries agregadas (nada de """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).
@ -353,7 +461,11 @@ def home(request):
@login_required @login_required
def expense_list(request): def expense_list(request):
expenses = Expense.objects.filter(owner=request.user) # Las relaciones de traspaso van en select_related porque la tabla consulta
# expense.transfer en cada fila para decidir los enlaces de acción.
expenses = Expense.objects.filter(owner=request.user).select_related(
"transfer_out", "transfer_fee"
)
categories = Category.objects.filter(owner=request.user) categories = Category.objects.filter(owner=request.user)
@ -483,6 +595,13 @@ def expense_list(request):
@login_required @login_required
def expense_create(request): def expense_create(request):
if request.method == "POST": if request.method == "POST":
# Marcado como traspaso: esto no se guarda aquí. Un traspaso son dos patas
# que crea Transfer.sync(), así que se reconduce al formulario de traspaso
# con lo que ya hubiera escrito. Se mira el POST en bruto y antes de
# validar porque el gasto puede estar a medias y da igual: no se crea.
if request.POST.get("is_transfer"):
return redirect(_transfer_create_url(request.POST, "from_account"))
form = ExpenseForm(request.POST, user=request.user) form = ExpenseForm(request.POST, user=request.user)
if form.is_valid(): if form.is_valid():
expense = form.save(commit=False) expense = form.save(commit=False)
@ -510,6 +629,10 @@ def expense_edit(request, pk):
owner=request.user, owner=request.user,
) )
blocked = _reject_transfer_leg(request, expense)
if blocked:
return blocked
if request.method == "POST": if request.method == "POST":
form = ExpenseForm(request.POST or None, instance=expense, user=request.user) form = ExpenseForm(request.POST or None, instance=expense, user=request.user)
@ -538,6 +661,10 @@ def expense_delete(request, pk):
owner=request.user, owner=request.user,
) )
blocked = _reject_transfer_leg(request, expense)
if blocked:
return blocked
if request.method == "POST": if request.method == "POST":
expense.delete() expense.delete()
messages.success(request, "Gasto eliminado correctamente.") messages.success(request, "Gasto eliminado correctamente.")
@ -967,6 +1094,11 @@ def account_delete(request, pk):
@login_required @login_required
def income_create(request): def income_create(request):
if request.method == "POST": if request.method == "POST":
# Igual que en expense_create, pero la cuenta es el destino: el dinero
# entra en ella.
if request.POST.get("is_transfer"):
return redirect(_transfer_create_url(request.POST, "to_account"))
form = IncomeForm(request.POST, user=request.user) form = IncomeForm(request.POST, user=request.user)
if form.is_valid(): if form.is_valid():
income = form.save(commit=False) income = form.save(commit=False)
@ -984,7 +1116,9 @@ def income_create(request):
@login_required @login_required
def income_list(request): def income_list(request):
incomes = Income.objects.filter(owner=request.user).select_related("account") incomes = Income.objects.filter(owner=request.user).select_related(
"account", "transfer_in"
)
year_list = Income.objects.filter(owner=request.user).dates("date", "year") year_list = Income.objects.filter(owner=request.user).dates("date", "year")
@ -1064,6 +1198,10 @@ def income_list(request):
def income_edit(request, pk): def income_edit(request, pk):
income = get_object_or_404(Income, pk=pk, owner=request.user) income = get_object_or_404(Income, pk=pk, owner=request.user)
blocked = _reject_transfer_leg(request, income)
if blocked:
return blocked
if request.method == "POST": if request.method == "POST":
form = IncomeForm(request.POST, instance=income, user=request.user) form = IncomeForm(request.POST, instance=income, user=request.user)
if form.is_valid(): if form.is_valid():
@ -1082,6 +1220,10 @@ def income_edit(request, pk):
def income_delete(request, pk): def income_delete(request, pk):
income = get_object_or_404(Income, pk=pk, owner=request.user) income = get_object_or_404(Income, pk=pk, owner=request.user)
blocked = _reject_transfer_leg(request, income)
if blocked:
return blocked
if request.method == "POST": if request.method == "POST":
income.delete() income.delete()
messages.success(request, "Ingreso eliminado.") messages.success(request, "Ingreso eliminado.")
@ -1306,6 +1448,14 @@ def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk, owner=request.user) category = get_object_or_404(Category, pk=pk, owner=request.user)
if request.method == "POST": if request.method == "POST":
if category.is_system:
messages.error(
request,
f"No se puede eliminar «{category.name}»; la usa la aplicación para "
"clasificar los traspasos y sus comisiones."
)
return redirect("category_list")
try: try:
category.delete() category.delete()
except ProtectedError: except ProtectedError:
@ -1428,3 +1578,92 @@ def goal_delete(request, pk):
@login_required @login_required
def settings_index(request): def settings_index(request):
return render(request, "settings/index.html", {"active_menu": "settings"}) return render(request, "settings/index.html", {"active_menu": "settings"})
@login_required
def transfer_list(request):
transfers = Transfer.objects.filter(owner=request.user).select_related(
"from_account", "to_account"
)
return render(
request,
"transfers/list.html",
{
"active_menu": "transfers",
"transfers": transfers,
},
)
@login_required
def transfer_create(request):
if request.method == "POST":
form = TransferForm(request.POST, user=request.user)
if form.is_valid():
transfer = form.save(commit=False)
transfer.owner = request.user
# sync() guarda el traspaso y crea las patas en una sola transacción.
transfer.sync()
messages.success(request, "Traspaso creado.")
return redirect("transfer_list")
else:
# La URL puede traer fecha, importe y cuenta del formulario de gasto o de
# ingreso del que se venga.
form = TransferForm(user=request.user, initial=_transfer_initial(request))
return render(
request,
"transfers/form.html",
{
"active_menu": "transfers",
"form": form,
"title": "Nuevo traspaso",
},
)
@login_required
def transfer_edit(request, pk):
transfer = get_object_or_404(Transfer, pk=pk, owner=request.user)
if request.method == "POST":
form = TransferForm(request.POST, instance=transfer, user=request.user)
if form.is_valid():
transfer = form.save(commit=False)
transfer.sync()
messages.success(request, "Traspaso actualizado.")
return redirect("transfer_list")
else:
form = TransferForm(instance=transfer, user=request.user)
return render(
request,
"transfers/form.html",
{
"active_menu": "transfers",
"form": form,
"title": "Editar traspaso",
},
)
@login_required
def transfer_delete(request, pk):
transfer = get_object_or_404(Transfer, pk=pk, owner=request.user)
if request.method == "POST":
# Transfer.delete() se lleva también las dos patas y la comisión.
transfer.delete()
messages.success(request, "Traspaso eliminado.")
return redirect("transfer_list")
return render(
request,
"transfers/confirm_delete.html",
{"active_menu": "transfers", "transfer": transfer},
)