Browse Source

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

jocelyn 8 years ago
parent
commit
0d68f6e891

+ 1 - 1
coin/billing/admin.py

@@ -78,7 +78,7 @@ class InvoiceAdmin(admin.ModelAdmin):
               ('member'),
               ('amount', 'amount_paid'),
               ('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__')
 
     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=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('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')])),
                 ('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")),

+ 83 - 16
coin/billing/models.py

@@ -5,11 +5,13 @@ import datetime
 import random
 import uuid
 import os
+import re
 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.dispatch import receiver
+from django.utils.encoding import python_2_unicode_compatible
 
 from coin.offers.models import OfferSubscription
 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
 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):
     """Nom et chemin du fichier pdf à stocker pour les factures"""
@@ -33,6 +28,66 @@ def invoice_pdf_filename(instance, filename):
                                       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')))
+
+
+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):
 
@@ -46,14 +101,14 @@ class Invoice(models.Model):
                                     help_text='Once validated, a PDF is generated'
                                     ' and the invoice cannot be modified')
     number = models.CharField(max_length=25,
-                              default=next_invoice_number,
                               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')
+        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,
@@ -67,6 +122,14 @@ class Invoice(models.Model):
                            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
@@ -111,17 +174,20 @@ class Invoice(models.Model):
         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
         """
-        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
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
@@ -136,6 +202,7 @@ class Invoice(models.Model):
     class Meta:
         verbose_name = 'facture'
 
+    objects = InvoiceQuerySet().as_manager()
 
 class InvoiceDetail(models.Model):
 

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

@@ -3,7 +3,7 @@
 {% block object-tools-items %}
     {% if not original.validated %}
         <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>
     {% endif %}
     {{ block.super }}

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

@@ -7,7 +7,7 @@
         <p>Émise le {{ invoice.date }}</p>
     </div>
     <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>
 

+ 33 - 1
coin/billing/tests.py

@@ -6,9 +6,10 @@ from decimal import Decimal
 
 from django.conf import settings
 from django.test import TestCase, Client
+from freezegun import freeze_time
 from coin.members.tests import MemberTestsUtils
 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.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
@@ -222,3 +223,34 @@ class BillingTests(TestCase):
 
         member_a.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.amount }}</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>
         {% empty %}
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>

+ 1 - 0
requirements.txt

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