123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- # -*- 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<year>\d{4})-(?P<month>\d{2})-(?P<index>\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()
|