diff --git a/expenses_manager/expenses/forms.py b/expenses_manager/expenses/forms.py index e2fed43..0221bca 100644 --- a/expenses_manager/expenses/forms.py +++ b/expenses_manager/expenses/forms.py @@ -1,5 +1,14 @@ 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, +) class ExpenseForm(forms.ModelForm): @@ -63,6 +72,36 @@ class IncomeForm(forms.ModelForm): ) +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): odometer = forms.DecimalField(label="Kilómetros actuales") liters = forms.DecimalField(max_digits=8, decimal_places=2, label="Litros") diff --git a/expenses_manager/expenses/migrations/0014_category_is_system_transfer.py b/expenses_manager/expenses/migrations/0014_category_is_system_transfer.py new file mode 100644 index 0000000..1540b50 --- /dev/null +++ b/expenses_manager/expenses/migrations/0014_category_is_system_transfer.py @@ -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'], + }, + ), + ] diff --git a/expenses_manager/expenses/models.py b/expenses_manager/expenses/models.py index 97e0e19..973d859 100644 --- a/expenses_manager/expenses/models.py +++ b/expenses_manager/expenses/models.py @@ -1,6 +1,7 @@ from datetime import date 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.db.models import Sum from functools import cached_property @@ -26,6 +27,11 @@ class Category(models.Model): related_name="children", ) + is_system = models.BooleanField( + default=False, + help_text="Categoría creada por la app (traspasos, comisiones). No borrable.", + ) + class Meta: unique_together = ("name", "parent", "owner", "slug") verbose_name_plural = "categories" @@ -246,6 +252,22 @@ class Expense(models.Model): def __str__(self): 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): owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) @@ -275,6 +297,208 @@ class Income(models.Model): def __str__(self): 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()). + expense_leg = models.OneToOneField( + Expense, on_delete=models.SET_NULL, null=True, related_name="transfer_out" + ) + income_leg = models.OneToOneField( + Income, on_delete=models.SET_NULL, null=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): expense = models.OneToOneField( diff --git a/expenses_manager/expenses/templates/categories/confirm_delete.html b/expenses_manager/expenses/templates/categories/confirm_delete.html index 98c5bf3..59702a4 100644 --- a/expenses_manager/expenses/templates/categories/confirm_delete.html +++ b/expenses_manager/expenses/templates/categories/confirm_delete.html @@ -7,7 +7,12 @@ {% block delete_question %}¿Seguro que quieres eliminar la categoría {{ category.name }}?{% endblock %} {% block delete_warnings %} - {% if expense_count %} + {% if category.is_system %} +
+ Esta categoría la usa la aplicación para clasificar los traspasos y sus + comisiones, así que no se puede eliminar. +
+ {% elif expense_count %}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. diff --git a/expenses_manager/expenses/templates/categories/list.html b/expenses_manager/expenses/templates/categories/list.html index ca0491d..cb2ba81 100644 --- a/expenses_manager/expenses/templates/categories/list.html +++ b/expenses_manager/expenses/templates/categories/list.html @@ -30,11 +30,18 @@
{% for row in category_rows %}