Parcourir la source

Fix invoice monthly creation failures

Fabs il y a 11 ans
Parent
commit
e250968e92

+ 82 - 51
coin/billing/create_subscriptions_invoices.py

@@ -1,7 +1,9 @@
 # -*- coding: utf-8 -*-
 import datetime
+from decimal import Decimal
 from dateutil.relativedelta import relativedelta
 from django.http import HttpResponse
+from django.db import transaction
 from django.db.models import Q
 from coin.offers.models import Offer, OfferSubscription
 from coin.members.models import Member
@@ -9,74 +11,87 @@ from coin.billing.models import Invoice, InvoiceDetail
 
 
 
-def create_all_members_invoices_for_a_period(date):
+def create_all_members_invoices_for_a_period(date=datetime.date.today()):
     """
     Pour chaque membre ayant au moins un abonnement actif, génère les factures
     en prenant la date comme premier mois de la période de facturation
     """
     members = Member.objects.filter(
         Q(offersubscription__resign_date__isnull=True) |
-        Q(offersubscription__resign_date__gte=datetime.date.today()))
+        Q(offersubscription__resign_date__gte=date))
 
     for member in members:
         create_member_invoice_for_a_period(member,date)
 
-
+@transaction.atomic
 def create_member_invoice_for_a_period(member, date):
     """
     Créé si necessaire une facture pour un membre en prenant la date passée
     en paramètre comme premier mois de période. Renvoi la facture générée
-    ou None si aucune facture n'était pas necessaire.
+    ou None si aucune facture n'était necessaire.
     """
-    invoice = None
+    sid = transaction.savepoint()
+
+    date_first_of_month = datetime.date(date.year, date.month, 1)
+    date_last_of_month = (date_first_of_month + relativedelta(months=+1) -
+                          relativedelta(days=+1))
+
+    invoice = Invoice.objects.create(
+        date_due=datetime.date.today(),
+        member=member
+    )
 
-    # Récupère les abonnements en cours du membre
-    offer_subscriptions = member.get_active_subscriptions(date)
+    # Récupère les abonnements actifs du membre à la fin du mois
+    offer_subscriptions = member.get_active_subscriptions(date_last_of_month)
 
     # Pour chaque abonnement
     for offer_subscription in offer_subscriptions:
         # Récupère l'offre de l'abonnement
         offer = offer_subscription.offer
 
-        # Recherche dans les factures déjà existantes de ce membre des items
-        # ayant cet abonnement pour lesquels la période de facturation englobe
-        # la date
+        # Vérifie s'il s'agit de la première facture d'un abonnement,
+        # Alors facture en plus les frais de mise en service
+        invoicedetail_test_first = InvoiceDetail.objects.filter(
+            offersubscription__exact=offer_subscription.pk,
+            invoice__member__exact=member.pk)
+        if not invoicedetail_test_first.exists():
+           invoice.details.create(
+                label=offer.name + " - Frais de mise en service",
+                amount=offer.initial_fees,
+                offersubscription=offer_subscription,
+                period_from=None,
+                period_to=None)
+
+        # Période de facturation de l'item par defaut
+        # - Du début du mois de la date passée en paramètre
+        # - Jusqu'à la fin du mois de la période de facturation de l'offre
+        period_from = date_first_of_month
+        period_to = (date_first_of_month +
+                     relativedelta(months=+offer.billing_period) -
+                     relativedelta(days=+1))
+        planned_period_number_of_days = (period_to - period_from).days + 1
+        quantity = 1
+
+        # Si la facture est le premier mois de l'abonnement, alors met la 
+        # date de début de facturation au jour de l'ouverture de
+        # l'abonnement
+        if date_first_of_month == datetime.date(
+            offer_subscription.subscription_date.year, 
+            offer_subscription.subscription_date.month, 1):
+            period_from = offer_subscription.subscription_date
+
+        # Recherche dans les factures déjà existantes de ce membre des
+        # items ayant cet abonnement pour lesquels la période de
+        # facturation englobe le début de notre période de facturation
+        # actuelle
         invoicedetail_test_before = InvoiceDetail.objects.filter(
             offersubscription__exact=offer_subscription.pk,
-            period_from__lte=date,
-            period_to__gte=date,
+            period_from__lte=period_from,
+            period_to__gt=period_from,
             invoice__member__exact=member.pk)
 
-        # Si une telle facture n'existe pas, alors ajout l'item à facturer à la
-        # facture à générer
+        # Si une facture de ce genre existe alors ne fait rien.
         if not invoicedetail_test_before.exists():
-            # Si l'object facture n'a pas encore été créé, le créé
-            if invoice is None:
-                invoice = Invoice.objects.create(
-                    date_due=datetime.date.today(),
-                    member=member
-                )
-
-            # Vérifie s'il s'agit de la première facture d'un abonnement,
-            # Alors facture en plus les frais de mise en service
-            invoicedetail_test_first = InvoiceDetail.objects.filter(
-                offersubscription__exact=offer_subscription.pk,
-                invoice__member__exact=member.pk)
-            if not invoicedetail_test_first.exists():
-                invoice.details.create(
-                    label=offer.name + " - Frais de mise en service",
-                    amount=offer.initial_fees,
-                    offersubscription=offer_subscription,
-                    period_from=None,
-                    period_to=None)
-
-            # Période de facturation de l'item par defaut
-            # - Du début du mois de la date passée en paramètre
-            # - Jusqu'à la fin du mois de la période de facturation de l'offre
-            period_from = datetime.date(date.year, date.month, 1)
-            period_to = (datetime.date(date.year, date.month, 1) +
-                         relativedelta(months=+offer.billing_period) -
-                         relativedelta(days=+1))
 
             # Recherche dans les factures déjà existantes de ce membre des
             # items ayant cet abonnement pour lesquels la période de
@@ -97,13 +112,29 @@ def create_member_invoice_for_a_period(member, date):
                                   invoicedetail_after.period_from.month, 1) -
                     relativedelta(days=+1))
 
-            # Ajout l'item de l'offre correspondant à l'abonnement à la facture
-            invoice.details.create(label=offer.name,
-                                   amount=offer.period_fees,
-                                   offersubscription=offer_subscription,
-                                   period_from=period_from,
-                                   period_to=period_to)
-
-            invoice.save()
-
-    return invoice
+            # Si la période de facturation varie par rapport à celle prévue par
+            # l'offre, calcul au prorata en faisant varier la quantité
+            period_number_of_days = (period_to - period_from).days + 1
+            if (planned_period_number_of_days != period_number_of_days):
+                quantity = (Decimal(period_number_of_days) /
+                            Decimal(planned_period_number_of_days))
+
+            # Si durée de 0jours ou dates incohérentes, alors on ajoute pas
+            # (Si la period est de 0jours c'est que la facture existe déjà.)
+            if (period_from<period_to):
+                # Ajout l'item de l'offre correspondant à l'abonnement à la facture
+                invoice.details.create(label=offer.name,
+                                       amount=offer.period_fees,
+                                       quantity=quantity,
+                                       offersubscription=offer_subscription,
+                                       period_from=period_from,
+                                       period_to=period_to)
+    
+    # S'il n'y a pas d'items dans la facture, ne commit pas la transaction.
+    if (invoice.details.count()>0):
+        invoice.save()
+        transaction.savepoint_commit(sid)
+        return invoice
+    else:
+        transaction.savepoint_rollback(sid)
+        return None

+ 94 - 0
coin/billing/migrations/0014_auto__chg_field_invoicedetail_quantity.py

@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+
+        # Changing field 'InvoiceDetail.quantity'
+        db.alter_column(u'billing_invoicedetail', 'quantity', self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=4, decimal_places=2))
+
+    def backwards(self, orm):
+
+        # Changing field 'InvoiceDetail.quantity'
+        db.alter_column(u'billing_invoicedetail', 'quantity', self.gf('django.db.models.fields.IntegerField')(null=True))
+
+    models = {
+        u'billing.invoice': {
+            'Meta': {'object_name': 'Invoice'},
+            'date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today', 'null': 'True'}),
+            'date_due': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2014, 3, 31, 0, 0)', 'null': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['members.Member']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'number': ('django.db.models.fields.CharField', [], {'default': "u'201403-872-331'", 'unique': 'True', 'max_length': '25'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'open'", 'max_length': '50'})
+        },
+        u'billing.invoicedetail': {
+            'Meta': {'object_name': 'InvoiceDetail'},
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'invoice': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'details'", 'to': u"orm['billing.Invoice']"}),
+            'label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'offersubscription': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['offers.OfferSubscription']", 'null': 'True', 'blank': 'True'}),
+            'period_from': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2014, 3, 1, 0, 0)', 'null': 'True', 'blank': 'True'}),
+            'period_to': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2014, 3, 31, 0, 0)', 'null': 'True', 'blank': 'True'}),
+            'quantity': ('django.db.models.fields.DecimalField', [], {'default': '1.0', 'null': 'True', 'max_digits': '4', 'decimal_places': '2'}),
+            'tax': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'null': 'True', 'max_digits': '4', 'decimal_places': '2'})
+        },
+        u'billing.payment': {
+            'Meta': {'object_name': 'Payment'},
+            'amount': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '7', 'decimal_places': '2'}),
+            'date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'invoice': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['billing.Invoice']"}),
+            'payment_mean': ('django.db.models.fields.CharField', [], {'default': "'transfer'", 'max_length': '100', 'null': 'True'})
+        },
+        u'members.member': {
+            'Meta': {'object_name': 'Member'},
+            'address': ('django.db.models.fields.TextField', [], {}),
+            'city': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'country': ('django.db.models.fields.CharField', [], {'default': "'France'", 'max_length': '200'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '254'}),
+            'entry_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'home_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'ldap_cn': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'mobile_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            'organization_name': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '15'}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'non_adherent'", 'max_length': '50'}),
+            'type': ('django.db.models.fields.CharField', [], {'default': "'personne_physique'", 'max_length': '20'})
+        },
+        u'offers.offer': {
+            'Meta': {'object_name': 'Offer'},
+            'billing_period': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'initial_fees': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'period_fees': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['offers.Service']"})
+        },
+        u'offers.offersubscription': {
+            'Meta': {'object_name': 'OfferSubscription'},
+            'commitment': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['members.Member']"}),
+            'offer': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['offers.Offer']"}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'subscription_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'})
+        },
+        u'offers.service': {
+            'Meta': {'object_name': 'Service'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        }
+    }
+
+    complete_apps = ['billing']

+ 2 - 2
coin/billing/models.py

@@ -66,8 +66,8 @@ class InvoiceDetail(models.Model):
     label = models.CharField(max_length=100)
     amount = models.DecimalField(max_digits=5, decimal_places=2,
                                  verbose_name='Montant')
-    quantity = models.IntegerField(null=True, verbose_name=u'Quantité',
-                                   default=1)
+    quantity = models.DecimalField(null=True, verbose_name=u'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 %')

+ 55 - 49
coin/billing/tests.py

@@ -1,86 +1,94 @@
 # -*- coding: utf-8 -*-
 import datetime
+from decimal import Decimal
 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.offers.models import Offer, Service, 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
 
 
 class BillingInvoiceCreationTests(TestCase):
 
+    def setUp(self):
+        # Créé une offre
+        self.service = Service(name='ServiceTest')
+        self.service.save()
+        self.offer = Offer(name='Offre', billing_period=3, period_fees=30,
+                           initial_fees=50, service=self.service)
+        self.offer.save()
+        # Créé un membre
+        self.ldap_cn = MemberTestsUtils.get_random_ldap_cn()
+        self.member = Member(first_name='Balthazar', last_name='Picsou',
+                             ldap_cn=self.ldap_cn)
+        self.member.save()
+        # Créé un abonnement
+        self.subscription = OfferSubscription(
+            subscription_date=datetime.date(2014, 1, 10),
+            member=self.member,
+            offer=self.offer)
+        self.subscription.save()
+    
+    def tearDown(self):
+        # Supprime l'utilisateur LDAP créé
+        LdapUser.objects.get(pk=self.ldap_cn).delete()
+
     def test_first_subscription_invoice_has_initial_fees(self):
         """
         Test que la première facture générée pour un abonnement possède les
         frais de mise en service
         """
-        # Créé une offre
-        service = Service(name='ServiceTest')
-        service.save()
-        offer = Offer(name='Offre', billing_period=3, period_fees=30,
-                      initial_fees=50, service=service)
-        offer.save()
-        # Créé un membre
-        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name='Balthazar', last_name='Picsou',
-                        ldap_cn=ldap_cn)
-        member.save()
-        # Créé un abonnement
-        subscription = OfferSubscription(
-            subscription_date=datetime.date(2014, 1, 1),
-            member=member,
-            offer=offer)
-        subscription.save()
         # Demande la création de la première facture
         invoice = create_member_invoice_for_a_period(
-            member, datetime.date(2014, 1, 1))
+            self.member, datetime.date(2014, 1, 1))
         # La facture doit avoir les frais de mise en service
         # Pour tester cela on tri par montant d'item décroissant.
         # Comme dans l'offre créé, les initial_fees sont plus élevées que
         # les period_fees, il doit sortir en premier
         self.assertEqual(invoice.details.order_by('-amount').first().amount, 50)
 
+    def test_prorata_for_first_month_subscription(self):
+        """
+        Test que la première facture d'un abonnement est facturée au prorata du
+        nombre de jours restants
+        """
+        #Créé la facture pour le mois de janvier
+        invoice = create_member_invoice_for_a_period(self.member, datetime.date(2014,1,1))
+        #Comme l'abonnement a été souscris le 10/01 et que la période de
+        #facturation est de 3 mois, alors le prorata doit être :
+        #janvier :  22j (31-9)
+        #fevrier :  28j
+        #mars :     31j
+        #22+28+31 / 31+28+31
+        quantity = Decimal((22.0+28.0+31.0)/(31.0+28.0+31.0))
+        for detail in invoice.details.all():
+            if detail.amount != 50:
+                self.assertEqual(detail.quantity.quantize(Decimal('0.01')),
+                                 quantity.quantize(Decimal('0.01')))
+
     def test_subscription_cant_be_charged_twice(self):
         """
         Test qu'un abonnement ne peut pas être facturé deux fois
         (pas de chevauchement possible)
         """
-
-        # Créé une offre
-        service = Service(name='ServiceTest')
-        service.save()
-        offer = Offer(name='Offre', billing_period=3, period_fees=30,
-                      initial_fees=0, service=service)
-        offer.save()
-        # Créé un membre
-        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name='Balthazar', last_name='Picsou',
-                        ldap_cn=ldap_cn)
-        member.save()
-        # Créé un abonnement
-        subscription = OfferSubscription(
-            subscription_date=datetime.date(2014, 1, 1),
-            member=member,
-            offer=offer)
-        subscription.save()
-
         # Créé une facture
-        invoice = Invoice(member=member)
+        invoice = Invoice(member=self.member)
         invoice.save()
         # Créé une facturation pour cet abonnement pour la première période
         # de janvier à mars
-        invoice.details.create(label=offer.name,
-                               amount=offer.period_fees,
-                               offersubscription=subscription,
+        invoice.details.create(label=self.offer.name,
+                               amount=self.offer.period_fees,
+                               offersubscription=self.subscription,
                                period_from=datetime.date(2014, 1, 1),
                                period_to=datetime.date(2014, 3, 31))
 
         # Créé une facturation pour cet abonnement pour une seconde période
         # de juin à aout
-        invoice.details.create(label=offer.name,
-                               amount=offer.period_fees,
-                               offersubscription=subscription,
+        invoice.details.create(label=self.offer.name,
+                               amount=self.offer.period_fees,
+                               offersubscription=self.subscription,
                                period_from=datetime.date(2014, 6, 1),
                                period_to=datetime.date(2014, 8, 31))
 
@@ -88,7 +96,7 @@ class BillingInvoiceCreationTests(TestCase):
         # Elle doit renvoyer None car l'offre est déjà facturée de
         # janvier à mars
         invoice_test_1 = create_member_invoice_for_a_period(
-            member, datetime.date(2014, 2, 1))
+            self.member, datetime.date(2014, 2, 1))
         self.assertEqual(invoice_test_1, None)
 
         # Demande la création d'une facture pour avril
@@ -96,14 +104,12 @@ class BillingInvoiceCreationTests(TestCase):
         # que de 2 mois, d'avril à mai car il y a déjà une facture pour
         # la période de juin à aout
         invoice_test_2 = create_member_invoice_for_a_period(
-            member, datetime.date(2014, 4, 1))
+            self.member, datetime.date(2014, 4, 1))
         self.assertEqual(invoice_test_2.details.first().period_from,
                          datetime.date(2014, 4, 1))
         self.assertEqual(invoice_test_2.details.first().period_to,
                          datetime.date(2014, 5, 31))
 
-        LdapUser.objects.get(pk=ldap_cn).delete()
-
 
 class BillingPDFTests(TestCase):
 
@@ -139,7 +145,7 @@ class BillingPDFTests(TestCase):
         Test qu'une erreur 403 est bien retournée en cas de tentative
         infructueuse
         """
-        # Créé un membre B
+        # Créé un membre A
         member_a_login = MemberTestsUtils.get_random_ldap_cn()
         member_a_pwd = '1234'
         member_a = Member(first_name='A', last_name='A',