# barbacompta - invoicing for dummies # Copyright (C) 2019-2020 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import datetime from collections import defaultdict from decimal import ROUND_HALF_UP, Decimal from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.validators import RegexValidator, validate_email from django.db import models from django.db.models import F, Q, Sum from django.db.models.query import QuerySet from django.db.models.signals import post_delete, post_save from django.template.loader import get_template from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from weasyprint import HTML from eo_gestion.utils import percentage_str from ..eo_banque import models as banque_models from . import facturx, fields, taggit, validators User = get_user_model() validate_telephone = RegexValidator(r"[. 0-9]*") DEFAULT_TVA = getattr(settings, "TVA", "20") def accounting_rounding(amount): return amount.quantize(Decimal("0.01"), ROUND_HALF_UP) def today(): return now().date() class Client(models.Model): nom = models.CharField(max_length=255, unique=True) adresse = models.TextField() email = models.CharField(max_length=50, validators=[validate_email], verbose_name='Courriel', blank=True) telephone = models.CharField( max_length=20, validators=[validate_telephone], verbose_name='Téléphone', blank=True ) contacts = models.TextField(verbose_name=_('Contacts'), blank=True) monnaie = models.CharField(max_length=10, default='€') tva = models.DecimalField( verbose_name='TVA par défaut', max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA) ) picture = models.ImageField('Logo', upload_to='logos/', blank=True, null=True) siret = models.CharField( max_length=len('29202001300010'), validators=[validators.validate_siret], db_index=True, blank=True, default='', ) service_code = models.CharField( max_length=128, db_index=True, blank=True, default='', ) active = models.BooleanField(verbose_name='Actif', default=True) chorus_structure = models.ForeignKey( 'chorus.Structure', verbose_name='Structure ChorusPro', null=True, blank=True, on_delete=models.SET_NULL, ) def __str__(self): # pylint: disable=invalid-str-returned return self.nom def clean(self): self.service_code = self.service_code.strip() if self.chorus_structure: self.siret = self.chorus_structure.siret self.service_code = self.chorus_structure.service_code class Meta: ordering = ('nom',) def one_hundred_percent_this_year(): return "%s:100" % now().date().year class Contrat(models.Model): client = models.ForeignKey(Client, related_name="contrats", on_delete=models.CASCADE) intitule = models.CharField(max_length=150) description = models.TextField(blank=True) public_description = models.TextField( verbose_name='Description publique', blank=True, help_text='Si elle est présente, cette description sera reprise dans la liste des références sur le site web.', ) url = models.URLField(verbose_name=_('URL'), blank=True) tva = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA)) creation = models.DateField(default=today) creator = models.ForeignKey(User, verbose_name='Créateur', on_delete=models.CASCADE) percentage_per_year = fields.PercentagePerYearField(default=one_hundred_percent_this_year) montant_sous_traite = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal('0')) image = models.ImageField('Image', upload_to='images/', blank=True, null=True) numero_marche = models.CharField(max_length=128, verbose_name='Numéro du marché', blank=True, default='') tags = taggit.TaggableManager(blank=True) def montant_facture(self): return sum(facture.montant for facture in self.factures.non_proforma()) def pourcentage_facture(self): return percentage_str(self.montant_facture(), self.montant()) pourcentage_facture.short_description = 'Pourcentage facturé' def montant(self): """ Montant total d'un contrat, y compris les prestations optionnelles. Si pas de prestation définie, montant des factures émises. """ if self.prestations.exists(): return Decimal(sum(p.montant() for p in self.prestations.all())) else: return self.montant_facture() def solde(self): return self.montant() - self.montant_facture() def nom_client(self): """ Le nom du client qui a signé ce contrat avec EO. """ return self.client.nom def clean(self): self.numero_marche = self.numero_marche.strip() def __str__(self): # pylint: disable=invalid-str-returned return self.intitule class Meta: ordering = ('-id',) class Prestation(models.Model): contrat = models.ForeignKey(Contrat, related_name='prestations', on_delete=models.CASCADE) intitule = models.CharField(max_length=255, verbose_name='Intitulé') optionnel = models.BooleanField(blank=True, default=False) prix_unitaire_ht = models.DecimalField(max_digits=8, decimal_places=2) quantite = models.DecimalField(max_digits=8, decimal_places=3) def montant(self): return accounting_rounding(self.prix_unitaire_ht * self.quantite) def duplicate(self): return Prestation( contrat=self.contrat, intitule=self.intitule, optionnel=self.optionnel, prix_unitaire_ht=self.prix_unitaire_ht, quantite=self.quantite, ) def __str__(self): return '%s pour %5.2f € HT' % ( self.intitule, accounting_rounding(self.prix_unitaire_ht * self.quantite), ) class Meta: ordering = ('contrat', 'id') DELAI_PAIEMENT = getattr(settings, "DELAI_PAIEMENT", 45) def today_plus_delai(): return today() + datetime.timedelta(days=DELAI_PAIEMENT) echeance_verbose_name = 'Échéance (par défaut %d jours)' % getattr(settings, 'DELAI_PAIEMENT', 45) class FactureQuerySet(QuerySet): def avec_solde(self): return self.non_proforma().filter(paid=False) def non_proforma(self): return self.filter(proforma=False) def for_year(self, year): return self.filter( Q(emission__year=year, account_on_previous_period=False) | Q(emission__year=year - 1, account_on_previous_period=True) ) class Facture(models.Model): proforma = models.BooleanField(default=True, verbose_name='Facture proforma', db_index=True) ordre = models.IntegerField( null=True, blank=True, verbose_name='Numéro de la facture dans l\'année', unique_for_year='emission', editable=False, ) client = models.ForeignKey( Client, related_name='direct_factures', null=True, blank=True, on_delete=models.CASCADE ) contrat = models.ForeignKey( Contrat, related_name='factures', blank=True, null=True, on_delete=models.CASCADE ) annulation = models.ForeignKey( 'Facture', related_name='factures_avoir', blank=True, null=True, on_delete=models.CASCADE ) intitule = models.CharField(max_length=150, verbose_name='Intitulé', blank=True) notes = models.TextField(blank=True) emission = models.DateField(verbose_name="Émission", default=today, db_index=True) echeance = models.DateField(verbose_name=echeance_verbose_name, default=today_plus_delai) taux_tva = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal(DEFAULT_TVA)) sous_traite = fields.EuroField( default=Decimal(0), verbose_name='Montant sous-traité', help_text='indiquer une somme pas un pourcentage, hors-taxe', ) paid = models.BooleanField(blank=True, verbose_name="Soldée", default=False, db_index=True) creator = models.ForeignKey(User, verbose_name='Créateur', on_delete=models.CASCADE) account_on_previous_period = models.BooleanField( verbose_name='Mettre cette facture sur l\'exercice précédent', default=False ) numero_engagement = models.CharField( max_length=128, verbose_name='Numéro d\'engagement', blank=True, default='' ) private_notes = models.TextField( blank=True, verbose_name='Notes privées', help_text='À usage purement interne, ne seront jamais présentes sur la facture', ) objects = FactureQuerySet.as_manager() def last_ordre_plus_one(self): ordre_max = Facture.objects.filter(emission__year=self.emission.year).aggregate(models.Max('ordre'))[ 'ordre__max' ] return (ordre_max or 0) + 1 def code(self): if not self.ordre and self.proforma: return 'Facture proforma du %s' % self.emission ctx = {} ctx.update(self.__dict__) ctx.update({'year': self.emission.year}) if ctx["ordre"] is None: return "Ordre is missing" format = getattr(settings, "FACTURE_CODE_FORMAT", "F%(year)s%(ordre)04d") return format % ctx @property def pk_or_code(self): # used in ModelAdmin to create URLs for humans if self.ordre is not None: return self.code() return self.pk def save(self, *args, **kwargs): if self.ordre is None and not self.proforma: self.ordre = self.last_ordre_plus_one() super().save(*args, **kwargs) def clean(self): if not (self.contrat or self.client): raise ValidationError("La facture doit avoir un client ou un contrat") if self.contrat: if not self.intitule: self.intitule = self.contrat.intitule if self.client: if self.client != self.contrat.client: raise ValidationError("Le client de la facture et du contrat doivent être identiques.") else: self.client = self.contrat.client else: if not self.intitule: raise ValidationError("La facture doit avoir un intitulé") if not self.proforma: try: for ligne in self.lignes.all(): ligne.clean() except ValidationError: raise ValidationError("Il y a un problème avec les lignes de cette facture") self.update_paid(save=False) self.numero_engagement = self.numero_engagement.strip() if ( not self.proforma and self.client and self.client.chorus_structure and self.client.chorus_structure.engagement_obligatoire ): if not self.numero_engagement: raise ValidationError('Numéro d\'engagement obligatoire pour Chorus') def index(self): if self.contrat: return list(self.contrat.factures.order_by('emission')).index(self) + 1 else: return 1 def __str__(self): return self.code() @property def tva(self): amount_by_vat = defaultdict(lambda: Decimal(0)) for ligne in self.lignes.all(): amount_by_vat[ligne.tva] += ligne.montant vat = Decimal(0) for vat_percentage, amount in amount_by_vat.items(): var_ratio = vat_percentage / Decimal("100") vat += accounting_rounding(var_ratio * amount) return vat @property def montant_ttc(self): return self.montant + self.tva @property def montant(self): return sum(ligne.montant for ligne in self.lignes.all()) def solde(self): payments = self.payments.all() if payments.count(): amount_paid = sum(payment.montant_affecte for payment in payments) else: amount_paid = Decimal(0) return amount_paid - self.montant_ttc def update_paid(self, save=True): new_paid = self.solde() >= 0 if self.paid != new_paid: self.paid = new_paid if save: self.save() @property def accounting_year(self): if self.account_on_previous_period: return self.emission.year - 1 return self.emission.year DEFAULT_FACTURE_TEMPLATE = 'facture.html' def html(self, template_name=None, base_uri=None): template = get_template(template_name or self.DEFAULT_FACTURE_TEMPLATE) return template.render({'facture': self, 'base_uri': base_uri or ''}) def pdf(self, template_name=None, base_uri=None): html = HTML(string=self.html(template_name=template_name, base_uri=base_uri)) pdf = html.write_pdf() return pdf def facturx_pdf(self, template_name=None, base_uri=None): if self.proforma: raise RuntimeError('facturx_pdf() can only be called on non proforma invoices') facturx_ctx = { 'numero_de_facture': self.code(), 'type_facture': '380' if not self.annulation else '381', # 380 => facture, 381 => avoir 'date_emission': self.emission, 'vendeur': settings.VENDOR_NAME, 'vendeur_siret': settings.VENDOR_SIRET, 'vendeur_tvai': settings.VENDOR_TVAI, 'client_siret': self.client.siret, 'client_name': self.client.nom, 'chorus_service_code': self.client.service_code, 'numero_engagement': self.numero_engagement, 'numero_marche': (self.contrat and self.contrat.numero_marche) or '', 'montant_ht': str(self.montant), 'montant_ttc': str(self.montant_ttc), 'montant_tva': str(self.tva), 'taux_tva': self.taux_tva or (self.contrat and self.contrat.tva) or self.client.tva or 0, 'annulation_code': self.annulation.code() if self.annulation else None, } return facturx.add_facturx_from_bytes( self.pdf(template_name=template_name, base_uri=base_uri), facturx_ctx ) def cancel(self, creator): assert not self.annulation, 'cannot cancel a canceled invoice' facture_avoir = Facture.objects.get(pk=self.pk) facture_avoir.pk = None facture_avoir.ordre = None facture_avoir.annulation = self facture_avoir.proforma = True facture_avoir.intitule = "AVOIR POUR LA FACTURE " + self.intitule facture_avoir.paid = False facture_avoir.creator = creator facture_avoir.account_on_previous_period = False facture_avoir.save() for ligne in self.lignes.all(): ligne.pk = None ligne.facture = facture_avoir ligne.quantite = ligne.quantite * -1 ligne.save() for payment in self.payments.all(): payment.pk = None payment.save() return facture_avoir class Meta: ordering = ("-id",) class Ligne(models.Model): facture = models.ForeignKey(Facture, related_name="lignes", on_delete=models.CASCADE) intitule = models.TextField(blank=True) prix_unitaire_ht = models.DecimalField(max_digits=8, decimal_places=2, default=Decimal('0')) quantite = models.DecimalField(max_digits=8, decimal_places=3, default=Decimal('1.0')) taux_tva = models.DecimalField(max_digits=4, decimal_places=2, blank=True, null=True) order = models.IntegerField(blank=True, null=True, default=0) @property def montant(self): return accounting_rounding(self.prix_unitaire_ht * self.quantite) @property def tva(self): if self.taux_tva is not None: return self.taux_tva elif self.facture.taux_tva: return self.facture.taux_tva elif self.facture.contrat and self.facture.contrat.tva: return self.facture.contrat.tva else: return self.facture.client.tva def clean(self): errors = [] if self.taux_tva and self.taux_tva < 0: errors.append("Le taux de tva doit être une valeur positive ou nulle.") if self.prix_unitaire_ht < 0: errors.append("Le prix unitaire hors taxe doit être une valeur positive ou nulle.") if self.facture.contrat and not self.facture.proforma: facture = self.facture contrat = facture.contrat deja_facture = sum(f.montant for f in contrat.factures.exclude(id=facture.id)) deja_facture += sum(l.montant for l in facture.lignes.all() if l != self) if deja_facture + self.montant > contrat.montant(): errors.append( 'Cette ligne fait dépasser le montant initial du contrat de %.2f %s' % (deja_facture + self.montant - contrat.montant(), contrat.client.monnaie) ) if errors: raise ValidationError(errors) class Meta: ordering = ("order",) def __str__(self): return "%s pour %s %s" % ( self.intitule, self.montant, self.facture.client.monnaie, ) def encaissements_avec_solde_non_affecte(): query = Q(montant_affecte__lt=F('montant')) | Q(montant_affecte__isnull=True) return ( banque_models.LigneBanquePop.objects.filter(montant__gt=0) .annotate(montant_affecte=Sum('payments__montant_affecte')) .filter(query) .order_by('-date_valeur') ) class Payment(models.Model): facture = models.ForeignKey(Facture, related_name='payments', on_delete=models.CASCADE) ligne_banque_pop = models.ForeignKey( banque_models.LigneBanquePop, related_name='payments', verbose_name='Encaissement', limit_choices_to={'montant__gt': 0}, on_delete=models.CASCADE, ) montant_affecte = models.DecimalField( max_digits=8, decimal_places=2, blank=True, verbose_name='Montant affecté', help_text="Si vide, le montant non affecté de l'encaissement est pris comme valeur", ) def clean(self): """Vérifie la cohérence des paiements""" try: if self.montant_affecte is None: self.montant_affecte = min(self.ligne_banque_pop.montant, self.facture.montant_ttc) except (banque_models.LigneBanquePop.DoesNotExist, Facture.DoesNotExist): pass aggregate = models.Sum("montant_affecte") # from the ligne de banque pov try: other_payments = self.ligne_banque_pop.payments if self.ligne_banque_pop.montant < 0 or (self.montant_affecte and self.montant_affecte <= 0): raise ValidationError("Un paiement ne peut être que d'un montant positif") except banque_models.LigneBanquePop.DoesNotExist: pass else: deja_affecte = other_payments.aggregate(aggregate).get("montant_affecte", 0) if deja_affecte + self.montant_affecte > self.ligne_banque_pop.montant: raise ValidationError( 'Le montant affecté aux différentes factures ' 'est supérieur au montant de l\'encaissement.' ) # from the facture pov try: other_payments = self.facture.payments except Facture.DoesNotExist: pass else: deja_affecte = other_payments.aggregate(aggregate).get("montant_affecte") or 0 if ( self.montant_affecte is not None and deja_affecte + self.montant_affecte > self.facture.montant_ttc ): raise ValidationError( 'Le montant affecté aux différentes factures ' 'est supérieur au montant de l\'encaissement.' ) def __str__(self): return 'Paiement de %.2f € sur facture %s à %s' % ( self.ligne_banque_pop.montant, self.facture, self.facture.client, ) class Meta: verbose_name = 'Paiement' ordering = ('-ligne_banque_pop__date_valeur',) def update_paid(sender, instance, raw=False, **kwargs): if raw: return instance.facture.update_paid() post_save.connect(update_paid, sender=Payment) post_delete.connect(update_paid, sender=Payment) post_save.connect(update_paid, sender=Ligne) post_delete.connect(update_paid, sender=Ligne)