expenses_manager/expenses_manager/expenses/models.py
JKuijperM 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

684 lines
21 KiB
Python

from datetime import date
from decimal import Decimal
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
from django.utils.text import slugify
from django.db.models.functions import ExtractMonth, Lower
class Category(models.Model):
name = models.CharField(max_length=100)
slug = models.SlugField(blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="categories",
)
parent = models.ForeignKey(
"self",
on_delete=models.CASCADE,
null=True,
blank=True,
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"
ordering = [Lower("name")]
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
from django.utils.text import slugify
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def descendant_ids(self, include_self=True):
"""IDs de esta categoría y de toda su descendencia."""
seen = {self.pk}
pending = [self.pk]
while pending:
children = list(
Category.objects.filter(parent_id__in=pending)
.exclude(pk__in=seen)
.values_list("pk", flat=True)
)
seen.update(children)
pending = children
return list(seen) if include_self else [i for i in seen if i != self.pk]
class Account(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="accounts",
)
name = models.CharField(max_length=100)
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = [Lower("name")]
def current_balance(self):
expenses_total = self.expenses.aggregate(total=Sum("amount"))[
"total"
] or Decimal("0")
income_total = self.incomes.aggregate(total=Sum("amount"))["total"] or Decimal(
"0"
)
return self.initial_balance + income_total - expenses_total
def monthly_balance(self, year=None):
year = year or date.today().year
today = date.today()
previous_income = (
self.incomes.filter(date__year__lt=year)
.aggregate(total=Sum('amount'))['total']
or Decimal('0')
)
previous_expenses = (
self.expenses.filter(date__year__lt=year)
.aggregate(total=Sum('amount'))['total']
or Decimal('0')
)
balance = self.initial_balance + previous_income - previous_expenses
incomes = (
self.incomes.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
expenses = (
self.expenses.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
income_map = {i["month"]: Decimal(str(i["total"] or 0)) for i in incomes}
expenses_map = {e["month"]: Decimal(str(e["total"] or 0)) for e in expenses}
data = []
for month in range(1, 13):
balance += income_map.get(month, Decimal("0"))
balance -= expenses_map.get(month, Decimal("0"))
data.append(
{
"month": month,
"balance": float(balance)
}
)
if year == today.year:
current_month_idx = today.month - 1
if 0 <= current_month_idx < len(data):
data[current_month_idx]["balance"] = float(self.current_balance())
return data
def balance_until(self, date):
incomes_total = self.incomes.filter(date__lte=date).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
expenses_total = self.expenses.filter(date__lte=date).aggregate(
total=Sum("amount")
)["total"] or Decimal("0")
return self.initial_balance + incomes_total - expenses_total
def monthly_net(self, year=None):
year = year or date.today().year
incomes = (
self.incomes.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
expenses = (
self.expenses.filter(date__year=year)
.annotate(month=ExtractMonth("date"))
.values("month")
.annotate(total=Sum("amount"))
)
income_map = {i["month"]: Decimal(str(i["total"] or 0)) for i in incomes}
expenses_map = {e["month"]: Decimal(str(e["total"] or 0)) for e in expenses}
return [
{
"month": month,
"net": float(
income_map.get(month, Decimal("0"))
- expenses_map.get(month, Decimal("0"))
),
}
for month in range(1, 13)
]
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="tags",
)
class Meta:
unique_together = ("name", "owner")
ordering = [Lower("name")]
def __str__(self):
return self.name
class Expense(models.Model):
date = models.DateField()
amount = models.DecimalField(max_digits=10, decimal_places=2)
description = models.TextField(blank=True)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="expenses",
)
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name="expenses",
)
account = models.ForeignKey(
Account,
on_delete=models.PROTECT,
related_name="expenses",
)
tags = models.ManyToManyField(
Tag,
blank=True,
related_name="expenses",
)
is_transfer = models.BooleanField(
default=False,
verbose_name="Es un traspaso entre cuentas",
help_text=(
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
"No contará como gasto ni como ingreso en los análisis, pero sí "
"afectará al saldo de la cuenta."
),
)
created_at = models.DateField(auto_now_add=True)
class Meta:
ordering = ["-date"]
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)
account = models.ForeignKey(
Account, on_delete=models.CASCADE, related_name="incomes"
)
name = models.CharField(max_length=150)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
is_transfer = models.BooleanField(
default=False,
verbose_name="Es un traspaso entre cuentas",
help_text=(
"Márcalo si este movimiento solo mueve dinero entre cuentas propias. "
"No contará como gasto ni como ingreso en los análisis, pero sí "
"afectará al saldo de la cuenta."
),
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-date"]
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()).
#
# 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):
expense = models.OneToOneField(
Expense, on_delete=models.CASCADE, related_name="fuel_data"
)
odometer = models.PositiveIntegerField() # kilometers
liters = models.DecimalField(max_digits=8, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["odometer"]
def km_since_previous(self):
previous = (
FuelEntry.objects.filter(
expense__owner=self.expense.owner, odometer__lt=self.odometer
)
.order_by("-odometer")
.first()
)
if previous:
return self.odometer - previous.odometer
return None
def price_per_liter(self):
if self.liters:
return self.expense.amount / self.liters
return 0
def consumption(self):
km = self.km_since_previous()
if km and km > 0:
return (self.liters / km) * 100
return None
class Goal(models.Model):
KIND_PAYMENT = "payment"
KIND_BUDGET = "budget"
KIND_SAVING = "saving"
KIND_CHOICES = [
(KIND_PAYMENT, "Pago / deuda"),
(KIND_BUDGET, "Presupuesto"),
(KIND_SAVING, "Ahorro"),
]
PERIOD_NONE = "none"
PERIOD_MONTH = "month"
PERIOD_YEAR = "year"
PERIOD_CHOICES = [
(PERIOD_NONE, "Sin reinicio"),
(PERIOD_MONTH, "Mensual"),
(PERIOD_YEAR, "Anual"),
]
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
target_amount = models.DecimalField(max_digits=12, decimal_places=2)
kind = models.CharField(
max_length=10, choices=KIND_CHOICES, default=KIND_PAYMENT
)
# Para pago y presupuesto
category = models.ForeignKey(
"Category", on_delete=models.CASCADE, null=True, blank=True
)
include_subcategories = models.BooleanField(default=True)
# Para ahorro (de momento se mide con el saldo de la cuenta)
account = models.ForeignKey(
"Account",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="goals",
)
# Desde cuándo cuenta (pago) / cada cuánto se reinicia (presupuesto)
start_date = models.DateField(null=True, blank=True)
period = models.CharField(
max_length=6, choices=PERIOD_CHOICES, default=PERIOD_NONE
)
created_at = models.DateTimeField(auto_now_add=True)
show_on_home = models.BooleanField(default=False)
def _period_start(self):
"""Fecha desde la que se cuenta el progreso, o None si es todo."""
if self.kind == self.KIND_BUDGET:
today = date.today()
if self.period == self.PERIOD_MONTH:
return date(today.year, today.month, 1)
if self.period == self.PERIOD_YEAR:
return date(today.year, 1, 1)
return self.start_date
@cached_property
def progress(self):
"""Progreso del objetivo. Memoizado: se calcula una vez por instancia.
Ojo: es una cached_property, no un método. Se accede como `goal.progress`
(sin paréntesis), tanto en código como en plantillas. El valor se cachea
en la instancia la primera vez; si en la misma petición se modifican
gastos y se necesita recalcular, habría que crear una instancia nueva.
"""
if self.kind == self.KIND_SAVING:
# Provisional: saldo de la cuenta asociada. Cuando exista el
# módulo de inversiones, esta rama es lo único que hay que tocar.
# Ojo: aquí los traspasos SÍ cuentan. Mandar dinero a la cuenta de
# ahorro es precisamente cómo se progresa en un objetivo de ahorro,
# así que no filtres is_transfer en esta rama.
return self.account.current_balance() if self.account else Decimal("0")
if not self.category:
return Decimal("0")
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
# Pago y presupuesto miden gasto real, así que los traspasos entre
# cuentas propias no cuentan. La rama de ahorro de arriba es la opuesta.
expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids
).exclude(is_transfer=True)
start = self._period_start()
if start:
expenses = expenses.filter(date__gte=start)
return expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
def percentage(self):
if not self.target_amount:
return 0
return (self.progress / self.target_amount) * 100
def bar_width(self):
return min(float(self.percentage()), 100)
def progress_state(self):
pct = self.percentage()
if self.kind == self.KIND_BUDGET:
if pct > 100:
return "danger"
if pct >= 80:
return "warning"
return "ok"
return "complete" if pct >= 100 else "ok"
def is_exceeded(self):
"""True si es un presupuesto que ya se ha pasado del límite."""
return (
self.kind == self.KIND_BUDGET
and self.progress > self.target_amount
)
def __str__(self):
return self.name