expenses_manager/expenses_manager/expenses/models.py

786 lines
26 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 Q, 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
def _deduped_total(movements):
"""Suma importes sin que el JOIN del M2M cuente la misma fila dos veces.
Un aggregate() sobre un queryset que lleva el JOIN de la vinculación dentro
suma cada movimiento tantas veces como objetivos tenga vinculados, y
.distinct() no lo arregla: el DISTINCT se aplica a las filas del SELECT
final y en un aggregate no hay filas que deduplicar, porque el JOIN ya
multiplicó antes de sumar. Sum("amount", distinct=True) es todavía peor,
porque deduplica por el valor del importe: dos gastos distintos de 10 € se
sumarían como uno.
Así que primero los ids (ahí el DISTINCT sí vale) y luego la suma. El
`pk__in` con un queryset dentro es una subconsulta, no un segundo viaje.
"""
ids = movements.values("pk").distinct()
total = movements.model.objects.filter(pk__in=ids).aggregate(
total=Sum("amount")
)["total"]
return total or Decimal("0")
class Goal(models.Model):
KIND_PAYMENT = "payment"
KIND_BUDGET = "budget"
KIND_SAVING = "saving"
KIND_COLLECTION = "collection"
KIND_CHOICES = [
(KIND_PAYMENT, "Pago / deuda"),
(KIND_BUDGET, "Presupuesto"),
(KIND_SAVING, "Ahorro"),
(KIND_COLLECTION, "Cobro pendiente"),
]
# Qué objetivos admiten vincular cada tipo de movimiento. Las dos vías de
# marcado (el formulario del movimiento y la pantalla del objetivo) leen de
# aquí: si cada una lo decidiera por su cuenta, acabarían divergiendo.
KINDS_LINKING_EXPENSES = (KIND_PAYMENT, KIND_BUDGET)
KINDS_LINKING_INCOMES = (KIND_COLLECTION,)
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)
# Movimientos sueltos vinculados a mano. Conviven con la selección por
# categoría, no la sustituyen: un objetivo puede tirar de categoría, de
# movimientos sueltos, o de los dos a la vez.
#
# blank=True y no null: un M2M no admite null. Borrar el objetivo limpia la
# tabla intermedia y no toca los movimientos, que es lo que se quiere.
linked_expenses = models.ManyToManyField(
"Expense", blank=True, related_name="goals", verbose_name="Gastos vinculados"
)
linked_incomes = models.ManyToManyField(
"Income", blank=True, related_name="goals", verbose_name="Ingresos vinculados"
)
@property
def links_incomes(self):
"""Si este objetivo se mide con ingresos en vez de con gastos."""
return self.kind in self.KINDS_LINKING_INCOMES
@property
def links(self):
"""El M2M que le toca a este objetivo, para no repetir el if por ahí."""
return self.linked_incomes if self.links_incomes else self.linked_expenses
@property
def accepts_links(self):
"""Si vincularle movimientos significa algo.
El ahorro no: se mide con el saldo de su cuenta, así que un gasto
vinculado ahí quedaría guardado y no contaría en el progreso.
"""
return self.kind in self.KINDS_LINKING_EXPENSES + self.KINDS_LINKING_INCOMES
def candidate_movements(self):
"""Movimientos que este objetivo puede vincular.
Único sitio donde se decide qué es vinculable; lo usan las dos vías de
marcado. Fuera las patas de un traspaso: mover dinero entre cuentas
propias no es ni un pago ni un cobro. La comisión sí entra, porque es
gasto real (va con is_transfer=False) y cuenta en los análisis.
"""
model = Income if self.links_incomes else Expense
movements = model.objects.filter(owner_id=self.owner_id).exclude(
is_transfer=True
)
start = self._period_start()
if start:
movements = movements.filter(date__gte=start)
return movements
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 self.kind == self.KIND_COLLECTION:
# Un cobro pendiente mide lo que te han devuelto, así que suma los
# ingresos vinculados a mano. Aquí no hay selección por categoría
# que valga: Income no tiene categoría, que es justo el motivo de
# que exista la vinculación.
incomes = Income.objects.filter(goals=self, owner_id=self.owner_id).exclude(
is_transfer=True
)
return _deduped_total(self._since_start(incomes))
category_ids = []
# Por _id y no por el objeto: mirar self.category traería la categoría
# de la base de datos, una consulta por objetivo al pintar una lista.
if self.category_id:
if self.include_subcategories:
category_ids = self.category.descendant_ids()
else:
category_ids = [self.category_id]
# Un solo queryset con un OR, y no dos sumas por separado: un gasto que
# esté en la categoría del objetivo Y además vinculado a mano se
# contaría dos veces. No daría error, daría un número mal.
scope = Q(goals=self)
if category_ids:
scope |= Q(category_id__in=category_ids)
# 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(scope, owner_id=self.owner_id).exclude(
is_transfer=True
)
return _deduped_total(self._since_start(expenses))
def _since_start(self, movements):
"""Acota al periodo del objetivo. El cobro pendiente lo necesita igual."""
start = self._period_start()
return movements.filter(date__gte=start) if start else movements
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