Browse Source

Use proper and legal sequential invoice numbers

Replacing previous random invoice numbers. The existing numbers remain
untouched in the DB, but no collision is possible because the number format is
different:

- old: "YYYYMM-RRR-RRR" (RRR-RRR being random numbers)
- new: "YYYY-MM-NNNNNN" (NNNNNN being sequence, local to the month)

fix #4
Jocelyn Delande 8 years ago
parent
commit
2f3776cdb0
4 changed files with 87 additions and 15 deletions
  1. 1 1
      coin/billing/admin.py
  2. 1 1
      coin/billing/migrations/0001_initial.py
  3. 64 12
      coin/billing/models.py
  4. 21 1
      coin/billing/tests.py

+ 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")),

+ 64 - 12
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, 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,7 +101,6 @@ 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,
@@ -117,15 +171,12 @@ class Invoice(models.Model):
         Switch invoice to validate mode. This set to False the draft field
         and generate the pdf
         """
+        self.number = Invoice.objects.get_next_invoice_number(self.date)
         self.validated = True
         self.save()
         self.generate_pdf()
         assert self.pdf_exists()
 
-    def save(self, *args, **kwargs):
-        self.number = Invoice.objects.get_next_invoice_number(self.date)
-        super(Invoice, self).save(*args, **kwargs)
-
     def pdf_exists(self):
         return (self.validated
                 and bool(self.pdf)
@@ -141,6 +192,7 @@ class Invoice(models.Model):
     class Meta:
         verbose_name = 'facture'
 
+    objects = InvoiceQuerySet().as_manager()
 
 class InvoiceDetail(models.Model):
 

+ 21 - 1
coin/billing/tests.py

@@ -8,7 +8,7 @@ from django.conf import settings
 from django.test import TestCase, Client
 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 +222,23 @@ 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')
+
+    def test_number_workflow(self):
+        iv = Invoice.objects.create()
+        self.assertEqual(iv.number, '')
+        iv.validate()
+        self.assertRegexpMatches(iv.number, r'.*-000001$')
+
+    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')