expenses_manager/expenses_manager/expenses/models.py
2026-07-24 08:06:22 +02:00

413 lines
12 KiB
Python

from datetime import date
from decimal import Decimal
from django.db import models
from django.conf import settings
from django.db.models import Sum
from django.db.models.fields import related
from django.db.models.functions import ExtractMonth
from django.utils.text import slugify
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",
)
class Meta:
unique_together = ("name", "parent", "owner", "slug")
verbose_name_plural = "categories"
ordering = ["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 = ["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")
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",
)
created_at = models.DateField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return "{} - {}".format(self.date, self.amount)
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()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-date"]
def __str__(self):
return f"{self.name} - {self.amount}"
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
def progress(self):
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.
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]
expenses = Expense.objects.filter(
owner=self.owner, category_id__in=category_ids
)
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