# -*- coding: utf-8 -*- from __future__ import unicode_literals import datetime import random import uuid import os import re from decimal import Decimal from django.db import models, transaction from django.db.models.signals import post_save from django.dispatch import receiver from django.utils.encoding import python_2_unicode_compatible from coin.offers.models import OfferSubscription from coin.members.models import Member from coin.html2pdf import render_as_pdf from coin.utils import private_files_storage, start_of_month, end_of_month, \ disable_for_loaddata, postgresql_regexp from coin.isp_database.context_processors import branding def invoice_pdf_filename(instance, filename): """Nom et chemin du fichier pdf à stocker pour les factures""" member_id = instance.member.id if instance.member else 0 return 'invoices/%d_%s_%s.pdf' % (member_id, instance.number, uuid.uuid4()) @python_2_unicode_compatible class InvoiceNumber: """ Logic and validation of invoice numbers Defines invoice numbers serie in a way that is legal in france. https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3 Our format is YYYY-MM-XXXXXX - YYYY the year of the bill - MM month of the bill - XXXXXX a per-month sequence """ RE_INVOICE_NUMBER = re.compile( r'(?P\d{4})-(?P\d{2})-(?P\d{6})') def __init__(self, date, index): self.date = date self.index = index def get_next(self): return InvoiceNumber(self.date, self.index + 1) def __str__(self): return '{:%Y-%m}-{:0>6}'.format(self.date, self.index) @classmethod def parse(cls, string): m = cls.RE_INVOICE_NUMBER.match(string) if not m: raise ValueError('Not a valid invoice number: "{}"'.format(string)) return cls( datetime.date( year=int(m.group('year')), month=int(m.group('month')), day=1), int(m.group('index'))) @staticmethod def time_sequence_filter(date, field_name='date'): """ Build queryset filter to be used to get the invoices from the numbering sequence of a given date. :param field_name: the invoice field name to filter on. :type date: datetime :rtype: dict """ return {'{}__month'.format(field_name): date.month} class InvoiceQuerySet(models.QuerySet): def get_next_invoice_number(self, date): last_invoice_number_str = self._get_last_invoice_number(date) if last_invoice_number_str is None: # It's the first bill of the month invoice_number = InvoiceNumber(date, 1) else: invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next() return str(invoice_number) def _get_last_invoice_number(self, date): same_seq_filter = InvoiceNumber.time_sequence_filter(date) return self.filter(**same_seq_filter).with_valid_number().aggregate( models.Max('number'))['number__max'] def with_valid_number(self): """ Excludes previous numbering schemes or draft invoices """ return self.filter(number__regex=postgresql_regexp( InvoiceNumber.RE_INVOICE_NUMBER)) class Invoice(models.Model): INVOICES_STATUS_CHOICES = ( ('open', 'A payer'), ('closed', 'Reglée'), ('trouble', 'Litige') ) validated = models.BooleanField(default=False, verbose_name='validée', help_text='Once validated, a PDF is generated' ' and the invoice cannot be modified') number = models.CharField(max_length=25, unique=True, verbose_name='numéro') status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES, default='open', verbose_name='statut') date = models.DateField( default=datetime.date.today, null=True, verbose_name='date', help_text='Cette date sera définie à la date de validation dans la facture finale') date_due = models.DateField( default=end_of_month, null=True, verbose_name="date d'échéance de paiement") member = models.ForeignKey(Member, null=True, blank=True, default=None, related_name='invoices', verbose_name='membre', on_delete=models.SET_NULL) pdf = models.FileField(storage=private_files_storage, upload_to=invoice_pdf_filename, null=True, blank=True, verbose_name='PDF') def save(self, *args, **kwargs): # First save to get a PK super(Invoice, self).save(*args, **kwargs) # Then use that pk to build draft invoice number if not self.validated and self.pk and not self.number: self.number = 'DRAFT-{}'.format(self.pk) self.save() def amount(self): """ Calcul le montant de la facture en fonction des éléments de détails """ total = Decimal('0.0') for detail in self.details.all(): total += detail.total() return total.quantize(Decimal('0.01')) amount.short_description = 'Montant' def amount_before_tax(self): total = Decimal('0.0') for detail in self.details.all(): total += detail.amount return total.quantize(Decimal('0.01')) amount.short_description = 'Montant HT' def amount_paid(self): """ Calcul le montant payé de la facture en fonction des éléments de paiements """ total = Decimal('0.0') for payment in self.payments.all(): total += payment.amount return total.quantize(Decimal('0.01')) amount_paid.short_description = 'Montant payé' def amount_remaining_to_pay(self): """ Calcul le montant restant à payer """ return self.amount() - self.amount_paid() amount_remaining_to_pay.short_description = 'Reste à payer' def has_owner(self, username): """ Check if passed username (ex gmajax) is owner of the invoice """ return (self.member and self.member.username == username) def generate_pdf(self): """ Make and store a pdf file for the invoice """ context = {"invoice": self} context.update(branding(None)) pdf_file = render_as_pdf('billing/invoice_pdf.html', context) self.pdf.save('%s.pdf' % self.number, pdf_file) @transaction.atomic def validate(self): """ Switch invoice to validate mode. This set to False the draft field and generate the pdf """ self.date = datetime.date.today() self.number = Invoice.objects.get_next_invoice_number(self.date) self.validated = True self.save() self.generate_pdf() assert self.pdf_exists() def pdf_exists(self): return (self.validated and bool(self.pdf) and private_files_storage.exists(self.pdf.name)) def get_absolute_url(self): from django.core.urlresolvers import reverse return reverse('billing:invoice', args=[self.number]) def __unicode__(self): return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due) class Meta: verbose_name = 'facture' objects = InvoiceQuerySet().as_manager() class InvoiceDetail(models.Model): label = models.CharField(max_length=100) amount = models.DecimalField(max_digits=5, decimal_places=2, verbose_name='montant') quantity = models.DecimalField(null=True, verbose_name='quantité', default=1.0, decimal_places=2, max_digits=4) tax = models.DecimalField(null=True, default=0.0, decimal_places=2, max_digits=4, verbose_name='TVA', help_text='en %') invoice = models.ForeignKey(Invoice, verbose_name='facture', related_name='details') offersubscription = models.ForeignKey(OfferSubscription, null=True, blank=True, default=None, verbose_name='abonnement') period_from = models.DateField( default=start_of_month, null=True, blank=True, verbose_name='début de période', help_text='Date de début de période sur laquelle est facturé cet item') period_to = models.DateField( default=end_of_month, null=True, blank=True, verbose_name='fin de période', help_text='Date de fin de période sur laquelle est facturé cet item') def __unicode__(self): return self.label def total(self): """Calcul le total""" return (self.amount * (self.tax / Decimal('100.0') + Decimal('1.0')) * self.quantity).quantize(Decimal('0.01')) class Meta: verbose_name = 'détail de facture' class Payment(models.Model): PAYMENT_MEAN_CHOICES = ( ('cash', 'Espèces'), ('check', 'Chèque'), ('transfer', 'Virement'), ('other', 'Autre') ) payment_mean = models.CharField(max_length=100, null=True, default='transfer', choices=PAYMENT_MEAN_CHOICES, verbose_name='moyen de paiement') amount = models.DecimalField(max_digits=5, decimal_places=2, null=True, verbose_name='montant') date = models.DateField(default=datetime.date.today) invoice = models.ForeignKey(Invoice, verbose_name='facture', related_name='payments') def __unicode__(self): return 'Paiment de %0.2f€' % self.amount class Meta: verbose_name = 'paiement' @receiver(post_save, sender=Payment) @disable_for_loaddata def set_invoice_as_paid_if_needed(sender, instance, **kwargs): """ Lorsqu'un paiement est enregistré, vérifie si la facture est alors complétement payée. Dans ce cas elle passe en réglée """ if (instance.invoice.amount_paid() >= instance.invoice.amount() and instance.invoice.status == 'open'): instance.invoice.status = 'closed' instance.invoice.save()