Sith/accounting/models.py

544 lines
17 KiB
Python
Raw Normal View History

#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
2024-09-23 08:25:27 +00:00
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
2024-06-24 11:07:36 +00:00
from decimal import Decimal
from django.conf import settings
2016-08-24 17:50:22 +00:00
from django.core import validators
2024-06-24 11:07:36 +00:00
from django.core.exceptions import ValidationError
2016-01-28 15:53:37 +00:00
from django.db import models
from django.template import defaultfilters
2024-06-24 11:07:36 +00:00
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from club.models import Club
2024-06-24 11:07:36 +00:00
from core.models import SithFile, User
2016-01-28 15:53:37 +00:00
2017-06-12 06:49:03 +00:00
2016-01-28 15:53:37 +00:00
class CurrencyField(models.DecimalField):
2024-07-12 07:34:16 +00:00
"""Custom database field used for currency."""
2018-10-04 19:29:19 +00:00
2016-01-28 15:53:37 +00:00
def __init__(self, *args, **kwargs):
2018-10-04 19:29:19 +00:00
kwargs["max_digits"] = 12
kwargs["decimal_places"] = 2
2024-06-27 12:46:43 +00:00
super().__init__(*args, **kwargs)
2016-01-28 15:53:37 +00:00
def to_python(self, value):
try:
2024-06-27 12:46:43 +00:00
return super().to_python(value).quantize(Decimal("0.01"))
2016-01-28 15:53:37 +00:00
except AttributeError:
2017-06-12 06:49:03 +00:00
return None
2016-01-28 15:53:37 +00:00
2018-10-04 19:29:19 +00:00
if settings.TESTING:
from model_bakery import baker
baker.generators.add(
CurrencyField,
lambda: baker.random_gen.gen_decimal(max_digits=8, decimal_places=2),
)
else: # pragma: no cover
# baker is only used in tests, so we don't need coverage for this part
pass
2016-08-07 18:10:50 +00:00
# Accounting classes
2017-06-12 06:49:03 +00:00
2016-08-07 18:10:50 +00:00
class Company(models.Model):
2018-10-04 19:29:19 +00:00
name = models.CharField(_("name"), max_length=60)
street = models.CharField(_("street"), max_length=60, blank=True)
city = models.CharField(_("city"), max_length=60, blank=True)
postcode = models.CharField(_("postcode"), max_length=10, blank=True)
country = models.CharField(_("country"), max_length=32, blank=True)
phone = PhoneNumberField(_("phone"), blank=True)
email = models.EmailField(_("email"), blank=True)
website = models.CharField(_("website"), max_length=64, blank=True)
2016-08-07 18:10:50 +00:00
class Meta:
verbose_name = _("company")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:co_edit", kwargs={"co_id": self.id})
def get_display_name(self):
return self.name
2017-02-05 14:50:42 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2017-02-05 14:50:42 +00:00
return True
return False
def can_be_edited_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
return user.memberships.filter(
end_date=None, club__role=settings.SITH_CLUB_ROLES_ID["Treasurer"]
).exists()
2017-02-05 14:50:42 +00:00
def can_be_viewed_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be viewed by the given user."""
return user.memberships.filter(
end_date=None, club__role_gte=settings.SITH_CLUB_ROLES_ID["Treasurer"]
).exists()
2017-02-05 14:50:42 +00:00
2017-06-12 06:49:03 +00:00
2016-05-09 09:49:01 +00:00
class BankAccount(models.Model):
2018-10-04 19:29:19 +00:00
name = models.CharField(_("name"), max_length=30)
iban = models.CharField(_("iban"), max_length=255, blank=True)
number = models.CharField(_("account number"), max_length=255, blank=True)
club = models.ForeignKey(
Club,
related_name="bank_accounts",
verbose_name=_("club"),
on_delete=models.CASCADE,
)
2016-05-09 09:49:01 +00:00
2016-08-24 19:49:46 +00:00
class Meta:
verbose_name = _("Bank account")
2018-10-04 19:29:19 +00:00
ordering = ["club", "name"]
2016-08-24 19:49:46 +00:00
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:bank_details", kwargs={"b_account_id": self.id})
2016-05-09 09:49:01 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2016-05-09 09:49:01 +00:00
return True
m = self.club.get_membership_for(user)
2018-10-04 19:29:19 +00:00
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
2016-05-09 09:49:01 +00:00
return True
return False
2017-06-12 06:49:03 +00:00
2016-05-09 09:49:01 +00:00
class ClubAccount(models.Model):
2018-10-04 19:29:19 +00:00
name = models.CharField(_("name"), max_length=30)
club = models.ForeignKey(
Club,
related_name="club_account",
verbose_name=_("club"),
on_delete=models.CASCADE,
)
2018-10-04 19:29:19 +00:00
bank_account = models.ForeignKey(
BankAccount,
related_name="club_accounts",
verbose_name=_("bank account"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
2016-08-24 19:49:46 +00:00
class Meta:
verbose_name = _("Club account")
2018-10-04 19:29:19 +00:00
ordering = ["bank_account", "name"]
2016-08-24 19:49:46 +00:00
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:club_details", kwargs={"c_account_id": self.id})
2016-05-09 09:49:01 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2016-05-09 09:49:01 +00:00
return True
return False
def can_be_edited_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
2016-05-09 09:49:01 +00:00
m = self.club.get_membership_for(user)
2018-10-04 19:29:19 +00:00
if m and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
2016-10-05 13:54:00 +00:00
return True
return False
def can_be_viewed_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be viewed by the given user."""
2016-10-05 13:54:00 +00:00
m = self.club.get_membership_for(user)
2018-10-04 19:29:19 +00:00
if m and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
2016-05-09 09:49:01 +00:00
return True
return False
2016-06-24 19:55:52 +00:00
def has_open_journal(self):
for j in self.journals.all():
if not j.closed:
return True
return False
2016-08-24 17:50:22 +00:00
def get_open_journal(self):
return self.journals.filter(closed=False).first()
2016-08-07 18:10:50 +00:00
def get_display_name(self):
2018-10-04 19:29:19 +00:00
return _("%(club_account)s on %(bank_account)s") % {
"club_account": self.name,
"bank_account": self.bank_account,
}
2016-08-07 18:10:50 +00:00
2016-05-09 09:49:01 +00:00
class GeneralJournal(models.Model):
2024-07-12 07:34:16 +00:00
"""Class storing all the operations for a period of time."""
2018-10-04 19:29:19 +00:00
start_date = models.DateField(_("start date"))
end_date = models.DateField(_("end date"), null=True, blank=True, default=None)
name = models.CharField(_("name"), max_length=40)
closed = models.BooleanField(_("is closed"), default=False)
club_account = models.ForeignKey(
ClubAccount,
related_name="journals",
null=False,
verbose_name=_("club account"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
amount = CurrencyField(_("amount"), default=0)
effective_amount = CurrencyField(_("effective_amount"), default=0)
2016-08-24 19:49:46 +00:00
class Meta:
verbose_name = _("General journal")
2018-10-04 19:29:19 +00:00
ordering = ["-start_date"]
2016-08-24 19:49:46 +00:00
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("accounting:journal_details", kwargs={"j_id": self.id})
2016-05-09 09:49:01 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2016-05-09 09:49:01 +00:00
return True
if self.club_account.can_be_edited_by(user):
return True
return False
2017-02-27 00:09:50 +00:00
def can_be_edited_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2017-02-27 00:09:50 +00:00
return True
if self.club_account.can_be_edited_by(user):
return True
return False
2016-10-05 13:54:00 +00:00
def can_be_viewed_by(self, user):
2017-06-26 09:16:47 +00:00
return self.club_account.can_be_viewed_by(user)
2016-10-05 13:54:00 +00:00
2016-06-22 11:19:35 +00:00
def update_amounts(self):
self.amount = 0
self.effective_amount = 0
for o in self.operations.all():
2016-08-24 17:50:22 +00:00
if o.accounting_type.movement_type == "CREDIT":
2016-06-22 11:19:35 +00:00
if o.done:
self.effective_amount += o.amount
self.amount += o.amount
else:
if o.done:
self.effective_amount -= o.amount
self.amount -= o.amount
self.save()
2016-05-03 06:50:54 +00:00
2017-06-12 06:49:03 +00:00
2016-05-09 09:49:01 +00:00
class Operation(models.Model):
2024-07-12 07:34:16 +00:00
"""An operation is a line in the journal, a debit or a credit."""
2018-10-04 19:29:19 +00:00
number = models.IntegerField(_("number"))
journal = models.ForeignKey(
GeneralJournal,
related_name="operations",
null=False,
verbose_name=_("journal"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
amount = CurrencyField(_("amount"))
date = models.DateField(_("date"))
remark = models.CharField(_("comment"), max_length=128, null=True, blank=True)
mode = models.CharField(
_("payment method"),
max_length=255,
choices=settings.SITH_ACCOUNTING_PAYMENT_METHOD,
)
cheque_number = models.CharField(
_("cheque number"), max_length=32, default="", null=True, blank=True
)
invoice = models.ForeignKey(
SithFile,
related_name="operations",
verbose_name=_("invoice"),
null=True,
blank=True,
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
done = models.BooleanField(_("is done"), default=False)
simpleaccounting_type = models.ForeignKey(
"SimplifiedAccountingType",
related_name="operations",
verbose_name=_("simple type"),
null=True,
blank=True,
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
accounting_type = models.ForeignKey(
"AccountingType",
related_name="operations",
verbose_name=_("accounting type"),
null=True,
blank=True,
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
label = models.ForeignKey(
"Label",
related_name="operations",
verbose_name=_("label"),
null=True,
blank=True,
on_delete=models.SET_NULL,
)
target_type = models.CharField(
_("target type"),
max_length=10,
choices=[
("USER", _("User")),
("CLUB", _("Club")),
("ACCOUNT", _("Account")),
("COMPANY", _("Company")),
("OTHER", _("Other")),
],
)
target_id = models.IntegerField(_("target id"), null=True, blank=True)
target_label = models.CharField(
_("target label"), max_length=32, default="", blank=True
)
linked_operation = models.OneToOneField(
"self",
related_name="operation_linked_to",
verbose_name=_("linked operation"),
null=True,
blank=True,
default=None,
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
2016-06-22 11:19:35 +00:00
class Meta:
2018-10-04 19:29:19 +00:00
unique_together = ("number", "journal")
ordering = ["-number"]
def __str__(self):
return f"{self.amount} € | {self.date} | {self.accounting_type} | {self.done}"
def save(self, *args, **kwargs):
if self.number is None:
self.number = self.journal.operations.count() + 1
super().save(*args, **kwargs)
self.journal.update_amounts()
def get_absolute_url(self):
return reverse("accounting:journal_details", kwargs={"j_id": self.journal.id})
2016-08-07 18:10:50 +00:00
def __getattribute__(self, attr):
if attr == "target":
return self.get_target()
else:
return object.__getattribute__(self, attr)
def clean(self):
2024-06-27 12:46:43 +00:00
super().clean()
if self.date is None:
raise ValidationError(_("The date must be set."))
elif self.date < self.journal.start_date:
2018-10-04 19:29:19 +00:00
raise ValidationError(
_(
"""The date can not be before the start date of the journal, which is
%(start_date)s."""
)
% {
"start_date": defaultfilters.date(
self.journal.start_date, settings.DATE_FORMAT
)
}
)
2016-08-07 18:10:50 +00:00
if self.target_type != "OTHER" and self.get_target() is None:
raise ValidationError(_("Target does not exists"))
if self.target_type == "OTHER" and self.target_label == "":
2018-10-04 19:29:19 +00:00
raise ValidationError(
_("Please add a target label if you set no existing target")
)
2016-08-24 17:50:22 +00:00
if not self.accounting_type and not self.simpleaccounting_type:
2018-10-04 19:29:19 +00:00
raise ValidationError(
_(
"You need to provide ether a simplified accounting type or a standard accounting type"
)
)
2016-08-24 17:50:22 +00:00
if self.simpleaccounting_type:
self.accounting_type = self.simpleaccounting_type.accounting_type
@property
def target(self):
return self.get_target()
2016-08-07 18:10:50 +00:00
def get_target(self):
tar = None
if self.target_type == "USER":
tar = User.objects.filter(id=self.target_id).first()
elif self.target_type == "CLUB":
tar = Club.objects.filter(id=self.target_id).first()
elif self.target_type == "ACCOUNT":
tar = ClubAccount.objects.filter(id=self.target_id).first()
elif self.target_type == "COMPANY":
tar = Company.objects.filter(id=self.target_id).first()
return tar
2016-05-09 09:49:01 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2016-05-09 09:49:01 +00:00
return True
2016-06-24 19:55:52 +00:00
if self.journal.closed:
return False
m = self.journal.club_account.club.get_membership_for(user)
2018-10-04 19:29:19 +00:00
if m is not None and m.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
2016-05-09 09:49:01 +00:00
return True
return False
def can_be_edited_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2017-02-27 00:09:50 +00:00
return True
if self.journal.closed:
return False
m = self.journal.club_account.club.get_membership_for(user)
2018-10-04 19:29:19 +00:00
if m is not None and m.role == settings.SITH_CLUB_ROLES_ID["Treasurer"]:
2016-05-09 09:49:01 +00:00
return True
return False
2016-06-22 11:19:35 +00:00
class AccountingType(models.Model):
2024-07-12 07:34:16 +00:00
"""Accounting types.
2016-06-22 11:19:35 +00:00
2024-07-12 07:34:16 +00:00
Those are numbers used in accounting to classify operations
2016-06-22 11:19:35 +00:00
"""
2018-10-04 19:29:19 +00:00
code = models.CharField(
_("code"),
max_length=16,
validators=[
validators.RegexValidator(
r"^[0-9]*$", _("An accounting type code contains only numbers")
)
],
)
label = models.CharField(_("label"), max_length=128)
movement_type = models.CharField(
_("movement type"),
choices=[
("CREDIT", _("Credit")),
("DEBIT", _("Debit")),
("NEUTRAL", _("Neutral")),
],
max_length=12,
2017-06-12 06:49:03 +00:00
)
2016-06-22 11:19:35 +00:00
2016-08-07 18:10:50 +00:00
class Meta:
verbose_name = _("accounting type")
2018-10-04 19:29:19 +00:00
ordering = ["movement_type", "code"]
2016-08-07 18:10:50 +00:00
def __str__(self):
return self.code + " - " + self.get_movement_type_display() + " - " + self.label
def get_absolute_url(self):
return reverse("accounting:type_list")
2016-06-22 11:19:35 +00:00
def is_owned_by(self, user):
2024-07-12 07:34:16 +00:00
"""Check if that object can be edited by the given user."""
if user.is_anonymous:
return False
if user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID):
2016-06-22 11:19:35 +00:00
return True
return False
2016-08-24 17:50:22 +00:00
class SimplifiedAccountingType(models.Model):
2024-07-12 07:34:16 +00:00
"""Simplified version of `AccountingType`."""
2018-10-04 19:29:19 +00:00
label = models.CharField(_("label"), max_length=128)
accounting_type = models.ForeignKey(
AccountingType,
related_name="simplified_types",
verbose_name=_("simplified accounting types"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
2016-08-24 17:50:22 +00:00
class Meta:
verbose_name = _("simplified type")
2018-10-04 19:29:19 +00:00
ordering = ["accounting_type__movement_type", "accounting_type__code"]
2016-08-24 17:50:22 +00:00
def __str__(self):
return (
f"{self.get_movement_type_display()} "
f"- {self.accounting_type.code} - {self.label}"
)
def get_absolute_url(self):
return reverse("accounting:simple_type_list")
2016-08-24 17:50:22 +00:00
@property
def movement_type(self):
return self.accounting_type.movement_type
def get_movement_type_display(self):
return self.accounting_type.get_movement_type_display()
2016-10-05 13:54:00 +00:00
class Label(models.Model):
2024-07-12 07:34:16 +00:00
"""Label allow a club to sort its operations."""
2018-10-04 19:29:19 +00:00
name = models.CharField(_("label"), max_length=64)
club_account = models.ForeignKey(
ClubAccount,
related_name="labels",
verbose_name=_("club account"),
on_delete=models.CASCADE,
2018-10-04 19:29:19 +00:00
)
2016-10-05 13:54:00 +00:00
class Meta:
2018-10-04 19:29:19 +00:00
unique_together = ("name", "club_account")
2016-10-05 13:54:00 +00:00
def __str__(self):
return "%s (%s)" % (self.name, self.club_account.name)
def get_absolute_url(self):
2018-10-04 19:29:19 +00:00
return reverse(
"accounting:label_list", kwargs={"clubaccount_id": self.club_account.id}
)
2016-10-05 13:54:00 +00:00
def is_owned_by(self, user):
if user.is_anonymous:
return False
2016-10-05 13:54:00 +00:00
return self.club_account.is_owned_by(user)
def can_be_edited_by(self, user):
return self.club_account.can_be_edited_by(user)
def can_be_viewed_by(self, user):
return self.club_account.can_be_viewed_by(user)