feat: modelo Transfer e is_system en Category
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f3ac2526ce
commit
7fd2f3361d
@ -1,5 +1,14 @@
|
|||||||
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExpenseForm(forms.ModelForm):
|
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):
|
class FuelEntryForm(forms.ModelForm):
|
||||||
odometer = forms.DecimalField(label="Kilómetros actuales")
|
odometer = forms.DecimalField(label="Kilómetros actuales")
|
||||||
liters = forms.DecimalField(max_digits=8, decimal_places=2, label="Litros")
|
liters = forms.DecimalField(max_digits=8, decimal_places=2, label="Litros")
|
||||||
|
|||||||
@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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,208 @@ 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()).
|
||||||
|
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):
|
class FuelEntry(models.Model):
|
||||||
expense = models.OneToOneField(
|
expense = models.OneToOneField(
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -1306,6 +1306,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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user