Parcourir la source

Merge branch 'jd-legalize-invoices' of FFDN/coin into master

jocelyn il y a 8 ans
Parent
commit
0d68f6e891

+ 1 - 1
coin/billing/admin.py

@@ -78,7 +78,7 @@ class InvoiceAdmin(admin.ModelAdmin):
               ('member'),
               ('member'),
               ('amount', 'amount_paid'),
               ('amount', 'amount_paid'),
               ('validated', 'pdf'))
               ('validated', 'pdf'))
-    readonly_fields = ('amount', 'amount_paid', 'validated', 'pdf')
+    readonly_fields = ('amount', 'amount_paid', 'validated', 'pdf', 'number')
     form = autocomplete_light.modelform_factory(Invoice, fields='__all__')
     form = autocomplete_light.modelform_factory(Invoice, fields='__all__')
 
 
     def get_readonly_fields(self, request, obj=None):
     def get_readonly_fields(self, request, obj=None):

+ 1 - 1
coin/billing/migrations/0001_initial.py

@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
             fields=[
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('validated', models.BooleanField(default=False, verbose_name='valid\xe9e')),
                 ('validated', models.BooleanField(default=False, verbose_name='valid\xe9e')),
-                ('number', models.CharField(default=coin.billing.models.next_invoice_number, unique=True, max_length=25, verbose_name='num\xe9ro')),
+                ('number', models.CharField(unique=True, max_length=25, verbose_name='num\xe9ro')),
                 ('status', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', 'A payer'), ('closed', 'Regl\xe9e'), ('trouble', 'Litige')])),
                 ('status', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', 'A payer'), ('closed', 'Regl\xe9e'), ('trouble', 'Litige')])),
                 ('date', models.DateField(default=datetime.date.today, null=True, verbose_name='date')),
                 ('date', models.DateField(default=datetime.date.today, null=True, verbose_name='date')),
                 ('date_due', models.DateField(default=coin.utils.end_of_month, null=True, verbose_name="date d'\xe9ch\xe9ance de paiement")),
                 ('date_due', models.DateField(default=coin.utils.end_of_month, null=True, verbose_name="date d'\xe9ch\xe9ance de paiement")),

+ 83 - 16
coin/billing/models.py

@@ -5,11 +5,13 @@ import datetime
 import random
 import random
 import uuid
 import uuid
 import os
 import os
+import re
 from decimal import Decimal
 from decimal import Decimal
 
 
-from django.db import models
+from django.db import models, transaction
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 from django.dispatch import receiver
 from django.dispatch import receiver
+from django.utils.encoding import python_2_unicode_compatible
 
 
 from coin.offers.models import OfferSubscription
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
 from coin.members.models import Member
@@ -18,13 +20,6 @@ from coin.utils import private_files_storage, start_of_month, end_of_month, \
                        disable_for_loaddata
                        disable_for_loaddata
 from coin.isp_database.context_processors import branding
 from coin.isp_database.context_processors import branding
 
 
-def next_invoice_number():
-    """Détermine un numéro de facture aléatoire"""
-    return '%s%02i-%i-%i' % (datetime.date.today().year,
-                             datetime.date.today().month,
-                             random.randrange(100, 999),
-                             random.randrange(100, 999))
-
 
 
 def invoice_pdf_filename(instance, filename):
 def invoice_pdf_filename(instance, filename):
     """Nom et chemin du fichier pdf à stocker pour les factures"""
     """Nom et chemin du fichier pdf à stocker pour les factures"""
@@ -33,6 +28,66 @@ def invoice_pdf_filename(instance, filename):
                                       instance.number,
                                       instance.number,
                                       uuid.uuid4())
                                       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')))
+
+
+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):
+        return self.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=InvoiceNumber.RE_INVOICE_NUMBER.pattern)
 
 
 class Invoice(models.Model):
 class Invoice(models.Model):
 
 
@@ -46,14 +101,14 @@ class Invoice(models.Model):
                                     help_text='Once validated, a PDF is generated'
                                     help_text='Once validated, a PDF is generated'
                                     ' and the invoice cannot be modified')
                                     ' and the invoice cannot be modified')
     number = models.CharField(max_length=25,
     number = models.CharField(max_length=25,
-                              default=next_invoice_number,
                               unique=True,
                               unique=True,
                               verbose_name='numéro')
                               verbose_name='numéro')
     status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
     status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
                               default='open',
                               default='open',
                               verbose_name='statut')
                               verbose_name='statut')
     date = models.DateField(
     date = models.DateField(
-        default=datetime.date.today, null=True, verbose_name='date')
+        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(
     date_due = models.DateField(
         default=end_of_month,
         default=end_of_month,
         null=True,
         null=True,
@@ -67,6 +122,14 @@ class Invoice(models.Model):
                            null=True, blank=True,
                            null=True, blank=True,
                            verbose_name='PDF')
                            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):
     def amount(self):
         """
         """
         Calcul le montant de la facture
         Calcul le montant de la facture
@@ -111,17 +174,20 @@ class Invoice(models.Model):
         pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
         pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
         self.pdf.save('%s.pdf' % self.number, pdf_file)
         self.pdf.save('%s.pdf' % self.number, pdf_file)
 
 
+    @transaction.atomic
     def validate(self):
     def validate(self):
         """
         """
         Switch invoice to validate mode. This set to False the draft field
         Switch invoice to validate mode. This set to False the draft field
         and generate the pdf
         and generate the pdf
         """
         """
-        if not self.is_pdf_exists():
-            self.validated = True
-            self.save()
-            self.generate_pdf()
-
-    def is_pdf_exists(self):
+        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
         return (self.validated
                 and bool(self.pdf)
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
                 and private_files_storage.exists(self.pdf.name))
@@ -136,6 +202,7 @@ class Invoice(models.Model):
     class Meta:
     class Meta:
         verbose_name = 'facture'
         verbose_name = 'facture'
 
 
+    objects = InvoiceQuerySet().as_manager()
 
 
 class InvoiceDetail(models.Model):
 class InvoiceDetail(models.Model):
 
 

+ 1 - 1
coin/billing/templates/admin/billing/invoice/change_form.html

@@ -3,7 +3,7 @@
 {% block object-tools-items %}
 {% block object-tools-items %}
     {% if not original.validated %}
     {% if not original.validated %}
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
-    {% elif original.is_pdf_exists %}
+    {% elif original.validated %}
         <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger le PDF</a></li>
         <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger le PDF</a></li>
     {% endif %}
     {% endif %}
     {{ block.super }}
     {{ block.super }}

+ 1 - 1
coin/billing/templates/billing/invoice.html

@@ -7,7 +7,7 @@
         <p>Émise le {{ invoice.date }}</p>
         <p>Émise le {{ invoice.date }}</p>
     </div>
     </div>
     <div class="large-4 columns">
     <div class="large-4 columns">
-        {% if invoice.is_pdf_exists %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
+        {% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
     </div>
     </div>
 </div>
 </div>
 
 

+ 33 - 1
coin/billing/tests.py

@@ -6,9 +6,10 @@ from decimal import Decimal
 
 
 from django.conf import settings
 from django.conf import settings
 from django.test import TestCase, Client
 from django.test import TestCase, Client
+from freezegun import freeze_time
 from coin.members.tests import MemberTestsUtils
 from coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
 from coin.members.models import Member, LdapUser
-from coin.billing.models import Invoice
+from coin.billing.models import Invoice, InvoiceQuerySet
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.models import Offer, OfferSubscription
 from coin.billing.create_subscriptions_invoices import create_member_invoice_for_a_period
 from coin.billing.create_subscriptions_invoices import create_member_invoice_for_a_period
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
@@ -222,3 +223,34 @@ class BillingTests(TestCase):
 
 
         member_a.delete()
         member_a.delete()
         member_b.delete()
         member_b.delete()
+
+
+class InvoiceQuerySetTests(TestCase):
+    def test_get_first_of_month_invoice_number(self):
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
+            '2016-01-000001')
+
+    @freeze_time('2016-01-01')
+    def test_number_workflow(self):
+        iv = Invoice.objects.create()
+        self.assertEqual(iv.number, 'DRAFT-1')
+        iv.validate()
+        self.assertRegexpMatches(iv.number, r'2016-01-000001$')
+
+    @freeze_time('2016-01-01')
+    def test_get_second_of_month_invoice_number(self):
+        first_bill = Invoice.objects.create(date=datetime.date(2016,1,1))
+        first_bill.validate()
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
+            '2016-01-000002')
+
+    def test_bill_date_is_validation_date(self):
+        bill = Invoice.objects.create(date=datetime.date(2016,1,1))
+        self.assertEqual(bill.date, datetime.date(2016,1,1))
+
+        with freeze_time('2017-01-01'):
+            bill.validate()
+            self.assertEqual(bill.date, datetime.date(2017, 1, 1))
+            self.assertEqual(bill.number, '2017-01-000001')

+ 1 - 1
coin/members/templates/members/invoices.html

@@ -20,7 +20,7 @@
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.amount }}</td>
             <td>{{ invoice.amount }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
-            <td>{% if invoice.is_pdf_exists %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
+            <td>{% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
         </tr>
         </tr>
         {% empty %}
         {% empty %}
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>

+ 1 - 0
requirements.txt

@@ -14,3 +14,4 @@ django-localflavor==1.1
 feedparser
 feedparser
 six==1.10.0
 six==1.10.0
 WeasyPrint==0.31
 WeasyPrint==0.31
+freezegun==0.3.8