mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-11-04 01:12:08 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			469 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			469 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
 | 
						|
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
						|
from datetime import date
 | 
						|
 | 
						|
from django.conf import settings
 | 
						|
from django.contrib.auth.models import User
 | 
						|
from django.core.exceptions import ValidationError
 | 
						|
from django.core.validators import MinValueValidator
 | 
						|
from django.db import models, transaction
 | 
						|
from django.db.models import Q
 | 
						|
from django.template.loader import render_to_string
 | 
						|
from django.utils import timezone
 | 
						|
from django.utils.translation import gettext_lazy as _
 | 
						|
# from member.models import Club, Membership # Club unused because of disabled soge
 | 
						|
from member.models import Membership
 | 
						|
from note.models import NoteSpecial, SpecialTransaction, MembershipTransaction, NoteUser
 | 
						|
 | 
						|
 | 
						|
class Invoice(models.Model):
 | 
						|
    """
 | 
						|
    An invoice model that can generates a true invoice.
 | 
						|
    """
 | 
						|
    id = models.PositiveIntegerField(
 | 
						|
        primary_key=True,
 | 
						|
        verbose_name=_("Invoice identifier"),
 | 
						|
    )
 | 
						|
 | 
						|
    bde = models.CharField(
 | 
						|
        max_length=32,
 | 
						|
        default='Diolistos',
 | 
						|
        choices=(
 | 
						|
            ('Diolistos', 'Diol[list]os'),
 | 
						|
            ('RavePartlist', 'RavePart[list]'),
 | 
						|
            ('SecretStorlist', 'SecretStor[list]'),
 | 
						|
            ('TotalistSpies', 'Tota[list]Spies'),
 | 
						|
            ('Saperlistpopette', 'Saper[list]popette'),
 | 
						|
            ('Finalist', 'Fina[list]'),
 | 
						|
            ('Listorique', '[List]orique'),
 | 
						|
            ('Satellist', 'Satel[list]'),
 | 
						|
            ('Monopolist', 'Monopo[list]'),
 | 
						|
            ('Kataclist', 'Katac[list]'),
 | 
						|
        ),
 | 
						|
        verbose_name=_("BDE"),
 | 
						|
    )
 | 
						|
    quotation = models.BooleanField(
 | 
						|
        default=False,
 | 
						|
        verbose_name=_("Quotation"),
 | 
						|
    )
 | 
						|
 | 
						|
    object = models.CharField(
 | 
						|
        max_length=255,
 | 
						|
        verbose_name=_("Object"),
 | 
						|
    )
 | 
						|
 | 
						|
    description = models.TextField(
 | 
						|
        verbose_name=_("Description")
 | 
						|
    )
 | 
						|
 | 
						|
    name = models.CharField(
 | 
						|
        max_length=255,
 | 
						|
        verbose_name=_("Name"),
 | 
						|
    )
 | 
						|
 | 
						|
    address = models.TextField(
 | 
						|
        verbose_name=_("Address"),
 | 
						|
    )
 | 
						|
 | 
						|
    date = models.DateField(
 | 
						|
        default=date.today,
 | 
						|
        verbose_name=_("Date"),
 | 
						|
    )
 | 
						|
 | 
						|
    payment_date = models.CharField(
 | 
						|
        default="",
 | 
						|
        max_length=255,
 | 
						|
        verbose_name=_("Payment date"),
 | 
						|
    )
 | 
						|
 | 
						|
    acquitted = models.BooleanField(
 | 
						|
        verbose_name=_("Acquitted"),
 | 
						|
        default=False,
 | 
						|
    )
 | 
						|
 | 
						|
    locked = models.BooleanField(
 | 
						|
        verbose_name=_("Locked"),
 | 
						|
        help_text=_("An invoice can't be edited when it is locked."),
 | 
						|
        default=False,
 | 
						|
    )
 | 
						|
 | 
						|
    tex = models.TextField(
 | 
						|
        default="",
 | 
						|
        verbose_name=_("tex source"),
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("invoice")
 | 
						|
        verbose_name_plural = _("invoices")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return _("Invoice #{id}").format(id=self.id)
 | 
						|
 | 
						|
    @transaction.atomic
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        """
 | 
						|
        When an invoice is generated, we store the tex source.
 | 
						|
        The advantage is to never change the template.
 | 
						|
        Warning: editing this model regenerate the tex source, so be careful.
 | 
						|
        """
 | 
						|
 | 
						|
        old_invoice = Invoice.objects.filter(id=self.id)
 | 
						|
        if old_invoice.exists():
 | 
						|
            if old_invoice.get().locked and not self._force_save:
 | 
						|
                raise ValidationError(_("This invoice is locked and can no longer be edited."))
 | 
						|
 | 
						|
        products = self.products.all()
 | 
						|
 | 
						|
        self.place = "Gif-sur-Yvette"
 | 
						|
        self.my_name = "BDE ENS Paris Saclay"
 | 
						|
        self.my_address_street = "4 avenue des Sciences"
 | 
						|
        self.my_city = "91190 Gif-sur-Yvette"
 | 
						|
        self.bank_code = 30003
 | 
						|
        self.desk_code = 3894
 | 
						|
        self.account_number = 37280662
 | 
						|
        self.rib_key = 14
 | 
						|
        self.bic = "SOGEFRPP"
 | 
						|
 | 
						|
        # Fill the template with the information
 | 
						|
        self.tex = render_to_string("treasury/invoice_sample.tex", dict(obj=self, products=products))
 | 
						|
 | 
						|
        return super().save(*args, **kwargs)
 | 
						|
 | 
						|
 | 
						|
class Product(models.Model):
 | 
						|
    """
 | 
						|
    Product that appears on an invoice.
 | 
						|
    """
 | 
						|
    invoice = models.ForeignKey(
 | 
						|
        Invoice,
 | 
						|
        on_delete=models.CASCADE,
 | 
						|
        related_name="products",
 | 
						|
        verbose_name=_("invoice"),
 | 
						|
    )
 | 
						|
 | 
						|
    designation = models.CharField(
 | 
						|
        max_length=255,
 | 
						|
        verbose_name=_("Designation"),
 | 
						|
    )
 | 
						|
 | 
						|
    quantity = models.DecimalField(
 | 
						|
        decimal_places=2,
 | 
						|
        max_digits=7,
 | 
						|
        verbose_name=_("Quantity"),
 | 
						|
        validators=[MinValueValidator(0)],
 | 
						|
    )
 | 
						|
 | 
						|
    amount = models.IntegerField(
 | 
						|
        verbose_name=_("Unit price"),
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("product")
 | 
						|
        verbose_name_plural = _("products")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return f"{self.designation} ({self.invoice})"
 | 
						|
 | 
						|
    @property
 | 
						|
    def amount_euros(self):
 | 
						|
        return "{:.2f}".format(self.amount / 100)
 | 
						|
 | 
						|
    @property
 | 
						|
    def total(self):
 | 
						|
        return self.quantity * self.amount
 | 
						|
 | 
						|
    @property
 | 
						|
    def total_euros(self):
 | 
						|
        return "{:.2f}".format(self.total / 100)
 | 
						|
 | 
						|
 | 
						|
class RemittanceType(models.Model):
 | 
						|
    """
 | 
						|
    Store what kind of remittances can be stored.
 | 
						|
    """
 | 
						|
    note = models.OneToOneField(
 | 
						|
        NoteSpecial,
 | 
						|
        on_delete=models.CASCADE,
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("remittance type")
 | 
						|
        verbose_name_plural = _("remittance types")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return str(self.note)
 | 
						|
 | 
						|
 | 
						|
class Remittance(models.Model):
 | 
						|
    """
 | 
						|
    Treasurers want to regroup checks or bank transfers in bank remittances.
 | 
						|
    """
 | 
						|
    date = models.DateTimeField(
 | 
						|
        default=timezone.now,
 | 
						|
        verbose_name=_("Date"),
 | 
						|
    )
 | 
						|
 | 
						|
    remittance_type = models.ForeignKey(
 | 
						|
        RemittanceType,
 | 
						|
        on_delete=models.PROTECT,
 | 
						|
        verbose_name=_("Type"),
 | 
						|
    )
 | 
						|
 | 
						|
    comment = models.CharField(
 | 
						|
        max_length=255,
 | 
						|
        verbose_name=_("Comment"),
 | 
						|
    )
 | 
						|
 | 
						|
    closed = models.BooleanField(
 | 
						|
        default=False,
 | 
						|
        verbose_name=_("Closed"),
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("remittance")
 | 
						|
        verbose_name_plural = _("remittances")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return _("Remittance #{:d}: {}").format(self.id, self.comment, )
 | 
						|
 | 
						|
    @transaction.atomic
 | 
						|
    def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
 | 
						|
        # Check if all transactions have the right type.
 | 
						|
        if self.transactions.exists() and self.transactions.filter(~Q(source=self.remittance_type.note)).exists():
 | 
						|
            raise ValidationError("All transactions in a remittance must have the same type")
 | 
						|
 | 
						|
        return super().save(force_insert, force_update, using, update_fields)
 | 
						|
 | 
						|
    @property
 | 
						|
    def transactions(self):
 | 
						|
        """
 | 
						|
        :return: Transactions linked to this remittance.
 | 
						|
        """
 | 
						|
        if not self.pk:
 | 
						|
            return SpecialTransaction.objects.none()
 | 
						|
        return SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self)
 | 
						|
 | 
						|
    def count(self):
 | 
						|
        """
 | 
						|
        Linked transactions count.
 | 
						|
        """
 | 
						|
        return self.transactions.count()
 | 
						|
 | 
						|
    @property
 | 
						|
    def amount(self):
 | 
						|
        """
 | 
						|
        Total amount of the remittance.
 | 
						|
        """
 | 
						|
        return sum(transaction.total for transaction in self.transactions.all())
 | 
						|
 | 
						|
 | 
						|
class SpecialTransactionProxy(models.Model):
 | 
						|
    """
 | 
						|
    In order to keep modularity, we don't that the Note app depends on the treasury app.
 | 
						|
    That's why we create a proxy in this app, to link special transactions and remittances.
 | 
						|
    If it isn't very clean, it does what we want.
 | 
						|
    """
 | 
						|
    transaction = models.OneToOneField(
 | 
						|
        SpecialTransaction,
 | 
						|
        on_delete=models.CASCADE,
 | 
						|
    )
 | 
						|
 | 
						|
    remittance = models.ForeignKey(
 | 
						|
        Remittance,
 | 
						|
        on_delete=models.PROTECT,
 | 
						|
        null=True,
 | 
						|
        related_name="transaction_proxies",
 | 
						|
        verbose_name=_("Remittance"),
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("special transaction proxy")
 | 
						|
        verbose_name_plural = _("special transaction proxies")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return str(self.transaction)
 | 
						|
 | 
						|
 | 
						|
class SogeCredit(models.Model):
 | 
						|
    """
 | 
						|
    Manage the credits from the Société générale.
 | 
						|
    """
 | 
						|
    user = models.OneToOneField(
 | 
						|
        User,
 | 
						|
        on_delete=models.PROTECT,
 | 
						|
        verbose_name=_("user"),
 | 
						|
    )
 | 
						|
 | 
						|
    transactions = models.ManyToManyField(
 | 
						|
        MembershipTransaction,
 | 
						|
        related_name="+",
 | 
						|
        blank=True,
 | 
						|
        verbose_name=_("membership transactions"),
 | 
						|
    )
 | 
						|
 | 
						|
    credit_transaction = models.OneToOneField(
 | 
						|
        SpecialTransaction,
 | 
						|
        on_delete=models.SET_NULL,
 | 
						|
        verbose_name=_("credit transaction"),
 | 
						|
        null=True,
 | 
						|
    )
 | 
						|
 | 
						|
    valid = models.BooleanField(
 | 
						|
        default=False,
 | 
						|
        verbose_name=_("Valid"),
 | 
						|
        blank=True,
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("Credit from the Société générale")
 | 
						|
        verbose_name_plural = _("Credits from the Société générale")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return _("Soge credit for {user}").format(user=str(self.user))
 | 
						|
 | 
						|
    @transaction.atomic
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        # This is a pre-registered user that declared that a SoGé account was opened.
 | 
						|
        # No note exists yet.
 | 
						|
        if not NoteUser.objects.filter(user=self.user).exists():
 | 
						|
            return super().save(*args, **kwargs)
 | 
						|
 | 
						|
        if not self.credit_transaction:
 | 
						|
            credit_transaction = SpecialTransaction(
 | 
						|
                source=NoteSpecial.objects.get(special_type="Virement bancaire"),
 | 
						|
                destination=self.user.note,
 | 
						|
                quantity=1,
 | 
						|
                amount=0,
 | 
						|
                reason="Crédit société générale",
 | 
						|
                last_name=self.user.last_name,
 | 
						|
                first_name=self.user.first_name,
 | 
						|
                bank="Société générale",
 | 
						|
                valid=True,
 | 
						|
            )
 | 
						|
            credit_transaction._force_save = True
 | 
						|
            credit_transaction.save()
 | 
						|
            credit_transaction.refresh_from_db()
 | 
						|
            self.credit_transaction = credit_transaction
 | 
						|
        elif not self.valid:
 | 
						|
            self.credit_transaction.amount = self.amount
 | 
						|
            self.credit_transaction._force_save = True
 | 
						|
            self.credit_transaction.save()
 | 
						|
 | 
						|
        return super().save(*args, **kwargs)
 | 
						|
 | 
						|
    @property
 | 
						|
    def valid_legacy(self):
 | 
						|
        return self.credit_transaction and self.credit_transaction.valid
 | 
						|
 | 
						|
    @property
 | 
						|
    def amount(self):
 | 
						|
        if self.valid_legacy:
 | 
						|
            return self.credit_transaction.total
 | 
						|
        amount = 0
 | 
						|
        transactions_wei = self.transactions.filter(membership__club__weiclub__isnull=False)
 | 
						|
        amount += sum(max(transaction.total - transaction.membership.club.weiclub.fee_soge_credit, 0) for transaction in transactions_wei)
 | 
						|
        transactions_not_wei = self.transactions.filter(membership__club__weiclub__isnull=True)
 | 
						|
        amount += sum(transaction.total for transaction in transactions_not_wei)
 | 
						|
        return amount
 | 
						|
 | 
						|
    def update_transactions(self):
 | 
						|
        """
 | 
						|
        The Sogé credit may be created after the user already paid its memberships.
 | 
						|
        We query transactions and update the credit, if it is unvalid.
 | 
						|
        """
 | 
						|
        if self.valid or not self.pk:
 | 
						|
            return
 | 
						|
 | 
						|
# Soge do not pay BDE and kfet memberships since 2022
 | 
						|
#        bde = Club.objects.get(name="BDE")
 | 
						|
#        kfet = Club.objects.get(name="Kfet")
 | 
						|
#        bde_qs = Membership.objects.filter(user=self.user, club=bde, date_start__gte=bde.membership_start)
 | 
						|
#        kfet_qs = Membership.objects.filter(user=self.user, club=kfet, date_start__gte=kfet.membership_start)
 | 
						|
 | 
						|
#        if bde_qs.exists():
 | 
						|
#            m = bde_qs.get()
 | 
						|
#            if MembershipTransaction.objects.filter(membership=m).exists():  # non-free membership
 | 
						|
#                if m.transaction not in self.transactions.all():
 | 
						|
#                    self.transactions.add(m.transaction)
 | 
						|
#
 | 
						|
#         if kfet_qs.exists():
 | 
						|
#             m = kfet_qs.get()
 | 
						|
#             if MembershipTransaction.objects.filter(membership=m).exists():  # non-free membership
 | 
						|
#                 if m.transaction not in self.transactions.all():
 | 
						|
#                     self.transactions.add(m.transaction)
 | 
						|
 | 
						|
        if 'wei' in settings.INSTALLED_APPS:
 | 
						|
            from wei.models import WEIClub
 | 
						|
            wei = WEIClub.objects.order_by('-year').first()
 | 
						|
            wei_qs = Membership.objects.filter(user=self.user, club=wei, date_start__gte=wei.membership_start)
 | 
						|
            if wei_qs.exists():
 | 
						|
                m = wei_qs.get()
 | 
						|
                if MembershipTransaction.objects.filter(membership=m).exists():  # non-free membership
 | 
						|
                    if m.transaction not in self.transactions.all():
 | 
						|
                        self.transactions.add(m.transaction)
 | 
						|
 | 
						|
        for tr in self.transactions.all():
 | 
						|
            tr.valid = True
 | 
						|
            tr.save()
 | 
						|
 | 
						|
    def invalidate(self):
 | 
						|
        """
 | 
						|
        Invalidating a Société générale delete the transaction of the bank if it was already created.
 | 
						|
        Treasurers must know what they do, With Great Power Comes Great Responsibility...
 | 
						|
        """
 | 
						|
        if self.valid:
 | 
						|
            self.credit_transaction.valid = False
 | 
						|
            self.credit_transaction.save()
 | 
						|
        for tr in self.transactions.all():
 | 
						|
            tr.valid = False
 | 
						|
            tr._force_save = True
 | 
						|
            tr.save()
 | 
						|
 | 
						|
    def validate(self, force=False):
 | 
						|
        if self.valid and not force:
 | 
						|
            # The credit is already done
 | 
						|
            return
 | 
						|
 | 
						|
        # First invalidate all transaction and delete the credit if already did (and force mode)
 | 
						|
        self.invalidate()
 | 
						|
        # Refresh credit amount
 | 
						|
        self.save()
 | 
						|
        self.valid = True
 | 
						|
        self.credit_transaction.valid = True
 | 
						|
        self.credit_transaction._force_save = True
 | 
						|
        self.credit_transaction.save()
 | 
						|
        self.save()
 | 
						|
 | 
						|
        for tr in self.transactions.all():
 | 
						|
            tr.valid = True
 | 
						|
            tr._force_save = True
 | 
						|
            tr.save()
 | 
						|
 | 
						|
    def delete(self, **kwargs):
 | 
						|
        """
 | 
						|
        Deleting a SogeCredit is equivalent to say that the Société générale didn't pay.
 | 
						|
        Treasurers must know what they do, this is difficult to undo this operation.
 | 
						|
        With Great Power Comes Great Responsibility...
 | 
						|
        """
 | 
						|
 | 
						|
        total_fee = self.amount
 | 
						|
        if self.user.note.balance < total_fee:
 | 
						|
            raise ValidationError(_("This user doesn't have enough money to pay the memberships with its note. "
 | 
						|
                                    "Please ask her/him to credit the note before invalidating this credit."))
 | 
						|
 | 
						|
        self.invalidate()
 | 
						|
        for tr in self.transactions.all():
 | 
						|
            tr._force_save = True
 | 
						|
            tr.valid = True
 | 
						|
            tr.save()
 | 
						|
        if self.credit_transaction:
 | 
						|
            # If the soge credit is deleted while the user is not validated yet,
 | 
						|
            # there is not credit transaction.
 | 
						|
            # There is a credit transaction if the user declares that no bank account
 | 
						|
            # was opened after the validation of the account.
 | 
						|
            self.credit_transaction.valid = False
 | 
						|
            self.credit_transaction.reason += " (invalide)"
 | 
						|
            self.credit_transaction._force_save = True
 | 
						|
            self.credit_transaction.save()
 | 
						|
        super().delete(**kwargs)
 |