Browse Source

Working prototype of automatic payment/invoice reconciliation

Alexandre Aubin 8 years ago
parent
commit
1aff009458
1 changed files with 146 additions and 14 deletions
  1. 146 14
      coin/billing/models.py

+ 146 - 14
coin/billing/models.py

@@ -139,6 +139,9 @@ class Invoice(models.Model):
                            null=True, blank=True,
                            verbose_name='PDF')
 
+    amount_paid = models.DecimalField(max_digits=5, decimal_places=2, default=0,
+                                      verbose_name='montant payé')
+
     def save(self, *args, **kwargs):
         # First save to get a PK
         super(Invoice, self).save(*args, **kwargs)
@@ -158,6 +161,7 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount.short_description = 'Montant'
 
+<<<<<<< HEAD
     def amount_before_tax(self):
         total = Decimal('0.0')
         for detail in self.details.all():
@@ -176,11 +180,13 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount_paid.short_description = 'Montant payé'
 
+=======
+>>>>>>> Working prototype of automatic payment/invoice reconciliation
     def amount_remaining_to_pay(self):
         """
         Calcul le montant restant à payer
         """
-        return self.amount() - self.amount_paid()
+        return self.amount() - self.amount_paid
     amount_remaining_to_pay.short_description = 'Reste à payer'
 
     def has_owner(self, username):
@@ -214,6 +220,8 @@ class Invoice(models.Model):
 
         assert self.pdf_exists()
 
+        update_accounting_for_member(self.member)
+
     def pdf_exists(self):
         return (self.validated
                 and bool(self.pdf)
@@ -231,6 +239,7 @@ class Invoice(models.Model):
 
     objects = InvoiceQuerySet().as_manager()
 
+
 class InvoiceDetail(models.Model):
 
     label = models.CharField(max_length=100)
@@ -282,9 +291,9 @@ class Payment(models.Model):
     )
 
     member = models.ForeignKey(Member, null=True, blank=True, default=None,
-		               related_name='payment',
-		               verbose_name='membre',
-		               on_delete=models.SET_NULL)
+                               related_name='payment',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
 
     payment_mean = models.CharField(max_length=100, null=True,
                                     default='transfer',
@@ -296,8 +305,33 @@ class Payment(models.Model):
     invoice = models.ForeignKey(Invoice, verbose_name='facture', null=True,
                                 blank=True, related_name='payments')
 
+    amount_already_allocated = models.DecimalField(max_digits=5,
+                                                   decimal_places=2,
+                                                   default=0.0,
+                                                   verbose_name='montant déjà alloué')
+
+    def amount_not_allocated(self):
+        return self.amount - self.amount_already_allocated
+
+
+    @transaction.atomic
+    def allocate_to_invoice(self, invoice):
+        amount_can_pay = self.amount_not_allocated()
+        amount_to_pay  = invoice.amount_remaining_to_pay()
+        amount_to_allocate = min(amount_can_pay, amount_to_pay)
+
+        print "Payment "+str(self.date)+" allocating "+str(amount_to_allocate)+" to invoice "+invoice.number
+
+        self.amount_already_allocated += amount_to_allocate
+        invoice.amount_paid           += amount_to_allocate
+
+        # Close invoice if relevant
+        if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
+            invoice.status = "closed"
+
+
     def __unicode__(self):
-        return 'Paiment de %0.2f€' % self.amount
+        return 'Paiment de %0.2f€ le %s' % (self.amount, str(self.date))
 
     class Meta:
         verbose_name = 'paiement'
@@ -305,15 +339,113 @@ class Payment(models.Model):
 
 @receiver(post_save, sender=Payment)
 @disable_for_loaddata
-def set_invoice_as_paid_if_needed(sender, instance, **kwargs):
+def update_accounting(sender, instance, **kwargs):
+
+    # Ignore if there's no member set for the invoice/payment
+    member = instance.member
+    if member == None :
+        return
+    else:
+        update_accounting_for_member(member)
+
+
+def update_accounting_for_member(member):
     """
-    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
+    Met à jour le status des factures, des paiements et le solde du compte
+    d'un utilisateur
     """
-    if instance.invoice == None :
-	return
 
-    if (instance.invoice.amount_paid() >= instance.invoice.amount() and
-            instance.invoice.status == 'open'):
-        instance.invoice.status = 'closed'
-        instance.invoice.save()
+    # Fetch relevant and active payments / invoices
+    # and sort then by chronological order : olders first, newers last.
+
+    this_member_payments = [p for p in Payment.objects.filter(member=member)
+                                                      .order_by("date")]
+    this_member_invoices = [i for i in Invoice.objects.filter(member=member)
+                                                      .filter(validated=True)
+                                                      .order_by("date")]
+
+    number_of_active_payments = len([p for p in this_member_payments if p.amount_not_allocated()    > 0])
+    number_of_active_invoices = len([p for p in this_member_invoices if p.amount_remaining_to_pay() > 0])
+
+    if (number_of_active_payments == 0):
+        print "No active payment for "+str(member)+". Nothing to do."
+    elif (number_of_active_invoices == 0):
+        print "No active invoice for "+str(member)+". Nothing to do."
+    else:
+        print "Initiating reconciliation between invoice and payments for user"+str(member)+"."
+        reconcile_invoices_and_payments(this_member_invoices, this_member_payments)
+    print " "
+    print " "
+    print " "
+    print " "
+
+
+def reconcile_invoices_and_payments(invoices, payments):
+    """
+    Rapproche des factures et des paiements qui sont actifs (paiement non alloué
+    ou factures non entièrement payées) automatiquement.
+    """
+
+    active_payments = [p for p in payments if p.amount_not_allocated()    > 0]
+    active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
+
+    print "Active payment, invoices"
+    print active_payments
+    print active_invoices
+
+    if (len(active_payments) == 0):
+        print "No more active payment. Nothing to reconcile anymore."
+        return
+    if (len(active_invoices) == 0):
+        print "No more active invoice. Nothing to reconcile anymore."
+        return
+
+    # Only consider the oldest active payment and the oldest active invoice
+
+    p = active_payments[0]
+    i = active_invoices[0]
+
+    # TODO : should add an assert that the ammount not allocated / remaining to
+    # pay is lower before and after calling the allocate_to_invoice
+
+    p.allocate_to_invoice(i)
+
+    # Reconcicle next payment / invoice
+
+    reconcile_invoices_and_payments(invoices, payments)
+
+
+
+def test_accounting_update():
+
+    Member.objects.all().delete()
+    Payment.objects.all().delete()
+    Invoice.objects.all().delete()
+
+    johndoe =  Member.objects.create(username="johndoe",
+                                     first_name="John",
+                                     last_name="Doe",
+                                     email="johndoe@yolo.test")
+    johndoe.set_password("trololo")
+
+
+    # First facture
+    invoice = Invoice.objects.create(number="1337",
+                                     member=johndoe)
+    InvoiceDetail.objects.create(label="superservice",
+                                 amount="15.0",
+                                 invoice=invoice)
+    invoice.validate()
+
+    # Second facture
+    invoice2 = Invoice.objects.create(number="42",
+                                     member=johndoe)
+    InvoiceDetail.objects.create(label="superservice",
+                                 amount="42",
+                                 invoice=invoice2)
+    invoice2.validate()
+
+    # Payment
+    payment = Payment.objects.create(amount=20,
+                                     member=johndoe)
+