133 Commits 7edf5b7601 ... b7239c40cb

Author SHA1 Message Date
  SimonBoulier b7239c40cb Merge of migrations 7 years ago
  SimonBoulier 3a05ba4abb Factorisation de code dans formfield_for_foreignkey 7 years ago
  SimonBoulier 800bffa6c8 Surcharge RowLevelPermission.save.() au lieu d'utiliser des signaux 7 years ago
  SimonBoulier 605b5b8390 Use slugify for get_automatic_codename 7 years ago
  Jocelyn Delalande 64de5f8ea3 Enhance member add form detection 7 years ago
  SimonBoulier 439ddcd62e Remove file TODO.md (part of the content will be in the PR description) 7 years ago
  SimonBoulier 5d9325b852 Replace get_manageable_offers and get_manageable_users by manageable_by, an operation of the manager 7 years ago
  SimonBoulier 0fbd9152b9 Automatically generate the codename of a RowLevelPermission 7 years ago
  SimonBoulier f72763ade7 Add a .distinct() 7 years ago
  Jocelyn Delalande 0198ecdf99 Update TODO 7 years ago
  Jocelyn Delalande e822570fe5 Nouvelle passe sur la section exemples 7 years ago
  Jocelyn Delalande f4135ecb51 Documente la logique de permissions 7 years ago
  Jocelyn Delalande 6de4d5db1d Déplacement de la doc utilisateur dans une hiérarchie dédiée 7 years ago
  Jocelyn Delalande 0029bdd368 Corrections sur la doc des permissions 7 years ago
  SimonBoulier cf176d7dcd Level permissions 7 years ago
  SimonBoulier 69dd3634dc Autocomplétion pour le champ membre dans la déclaration d'un emprunt. 7 years ago
  SimonBoulier 7edf5b7601 Factorisation de code dans formfield_for_foreignkey 7 years ago
  SimonBoulier de91d6b1b3 Surcharge RowLevelPermission.save.() au lieu d'utiliser des signaux 7 years ago
  SimonBoulier b1695a952d Use slugify for get_automatic_codename 7 years ago
  Jocelyn Delalande de3bb650aa Enhance member add form detection 7 years ago
  SimonBoulier ca69ff4e44 Remove file TODO.md (part of the content will be in the PR description) 7 years ago
  SimonBoulier 00a8e6b1a2 Replace get_manageable_offers and get_manageable_users by manageable_by, an operation of the manager 7 years ago
  SimonBoulier 7b7d9d5f7b Automatically generate the codename of a RowLevelPermission 7 years ago
  SimonBoulier 9ffec6ae92 Add a .distinct() 7 years ago
  Grégoire Jadi 6b2031dad2 hotfix: show the 'balance' field when creating a new member 7 years ago
  Grégoire Jadi a09344ede1 Fix missing method name 7 years ago
  Grégoire Jadi 1cf6772200 Overload MemberAdmin to update inlines instead of monkey-patching 7 years ago
  Jocelyn Delalande f7b90ae9a6 Document import_payments_from_csv command 7 years ago
  Jocelyn Delalande b6b9560c94 Fix last remains of our commit-merge-revert-mess about #96 7 years ago
  Jocelyn Delalande 750b7f0336 Migrations merge, after merge of ARN/flexible-payment 7 years ago
  Jocelyn Delalande 4e527a00b4 Merge remote-tracking branch 'ARN/flexible-accounting' 7 years ago
  Jocelyn Delalande 1144d992e3 Update TODO 7 years ago
  Jocelyn Delalande a6b6cb2c7c Nouvelle passe sur la section exemples 7 years ago
  Jocelyn Delalande 87be3d7169 Documente la logique de permissions 7 years ago
  Jocelyn Delalande 7c27e0bcaa Déplacement de la doc utilisateur dans une hiérarchie dédiée 7 years ago
  Jocelyn Delalande 85018e54b3 Corrections sur la doc des permissions 7 years ago
  SimonBoulier f62a5a0007 Level permissions 7 years ago
  SimonBoulier f84689cd1d Autocomplétion pour le champ membre dans la déclaration d'un emprunt. 7 years ago
  SimonBoulier 8545f768a9 Typo: get_mac_or_serial instead of get_mac_and_serial, raise an error when creating a new member 7 years ago
  Alexandre Aubin a7d10255b7 Fix encoding bullshit 7 years ago
  Alexandre Aubin 3159779e8c Fix case when deleting payment/invoice not associated to a member 7 years ago
  ljf 5ce4ffcb15 [enh] Edit balance in admin 7 years ago
  Alexandre Aubin e5c9afe8e8 Fix misc issues with CSV import 7 years ago
  Alexandre Aubin 288bacf4ca Removing .encode(utf-8) because it shouldnt be there 7 years ago
  Alexandre Aubin 674c7c7a2c Remove duplicate stuff in README 7 years ago
  Alexandre Aubin 7322842a6f Fixing tests 7 years ago
  Alexandre Aubin 63713b75c7 Fixing migrations 7 years ago
  Alexandre Aubin 72f2203968 Merge branch 'flexible-accounting' of https://code.ffdn.org/ARN/coin into flexible-accounting 7 years ago
  Alexandre Aubin b183527b91 Changing number for migration after rebase 7 years ago
  Alexandre Aubin 3f1444326a Moving test to tests.py 7 years ago
  Alexandre Aubin acc23fa38b Reopen an invoice after deleting a payment if relevant 7 years ago
  Alexandre Aubin 97fed35634 Handling deletion of payment 7 years ago
  Alexandre Aubin fd33615e6d Adding migrations for new billing system 7 years ago
  Alexandre Aubin 8c6dd3f17f Missing parenthesis 7 years ago
  Alexandre Aubin 8f09e71253 Adding a PaymentAllocation class to keep track of payment allocations.. 7 years ago
  Alexandre Aubin 627f206eb0 name() doesn't exists, use __unicode__ instead 7 years ago
  Alexandre Aubin d55c05a820 Adding instructions in README to configure accounting logs 7 years ago
  Alexandre Aubin b67994660f Moving accounting logs to console by default 7 years ago
  Alexandre Aubin 3e397b3760 Missing import for disable_for_loaddate 7 years ago
  Alexandre Aubin 4cd0b68279 Fix wrong attribute 7 years ago
  Alexandre Aubin 4bbd7857f6 Don't indent with respect to dots.. 7 years ago
  Alexandre Aubin f718b5c001 Disable invoice/payment change handling for loaddata 7 years ago
  Alexandre Aubin 00cad9039f Imports in alphabetical order 7 years ago
  Alexandre Aubin a6b1b79e9b Removing uncesseray add_arguments 7 years ago
  Alexandre Aubin e19e2fe5d1 Adding parenthesis for print 7 years ago
  Alexandre Aubin 8684fcdbf3 Moving imports at beginning of file 7 years ago
  Alexandre Aubin 85fd715100 camelCase -> snake_case 7 years ago
  Alexandre Aubin ebba32cf01 Move doc at beginning of script 7 years ago
  Alexandre Aubin ca62953e7d Remove unused method 7 years ago
  Alexandre Aubin 6aad9133b2 Small simplification 7 years ago
  Alexandre Aubin 8c66ad8da2 Only show paiements after an invoice is validated 7 years ago
  Alexandre Aubin 5a74c9ce8a Traduction fr des titres liés aux paiements dans l'admin 7 years ago
  Alexandre Aubin b2435565b2 Unmatch some payment containing specific keywords 7 years ago
  Alexandre Aubin f78301cfd1 Fixing encoding error for payment label in logging 7 years ago
  Alexandre Aubin a0f8841099 Properly manage case where a payment is added to an invoice specifically 7 years ago
  Alexandre Aubin 05295f24db Moving tests at the end of model 7 years ago
  Alexandre Aubin 5fe61506c4 From payment, update accounting only if created or non allocated 7 years ago
  Alexandre Aubin 52a7ee7aa3 Remove debug allowed host 7 years ago
  Alexandre Aubin d5165ea3d5 Trying to clarify reconciliation algorithm 7 years ago
  Alexandre Aubin 401d017d1a Moar specific logs when creating/updating payments/invoices 7 years ago
  Alexandre Aubin da77844cc1 Fixing how paiments are added in CSV import script 7 years ago
  Alexandre Aubin 4b84cc9126 Misc fixes in script to import payment from CSV 7 years ago
  Alexandre Aubin 60bb7a4305 Moving accounting log to /var/log/coin/ 7 years ago
  Alexandre Aubin ea0a159ecc [wip] Reminder for unpaid bills/invoices 7 years ago
  Alexandre Aubin 158b7b86b9 Tweaking admin interface for payments 7 years ago
  Alexandre Aubin 00d5d23e95 Adding logging for billing 7 years ago
  Alexandre Aubin 85402bbc72 Working CSV import script 8 years ago
  Alexandre Aubin 0fe3467826 Draft of import function with payment-member matching 8 years ago
  Alexandre Aubin be58fc0222 Displaying payment and balance in member interface 8 years ago
  Alexandre Aubin 1aff009458 Working prototype of automatic payment/invoice reconciliation 8 years ago
  Alexandre Aubin b466c5aa19 Separating bills from payment 8 years ago
  Alexandre Aubin 13fff5723b Moving test to tests.py 7 years ago
  Alexandre Aubin 5180042b99 Reopen an invoice after deleting a payment if relevant 7 years ago
  Alexandre Aubin 6ddd735613 Handling deletion of payment 7 years ago
  Alexandre Aubin e2d1bc343c Adding migrations for new billing system 7 years ago
  Alexandre Aubin b5f9991f4b Missing parenthesis 7 years ago
  Alexandre Aubin 1d5f7ba4fa Adding a PaymentAllocation class to keep track of payment allocations.. 7 years ago
  Alexandre Aubin d36d859f7d name() doesn't exists, use __unicode__ instead 7 years ago
  Alexandre Aubin f24c6a3b1e Adding instructions in README to configure accounting logs 7 years ago
  Alexandre Aubin 43a156b59e Moving accounting logs to console by default 7 years ago
  Alexandre Aubin c75867c898 Missing import for disable_for_loaddate 7 years ago
  Alexandre Aubin c248fcb53e Fix wrong attribute 7 years ago
  Alexandre Aubin 9e0de4024c Don't indent with respect to dots.. 7 years ago
  Alexandre Aubin 3b8cf40c18 Disable invoice/payment change handling for loaddata 7 years ago
  Alexandre Aubin 1288c15f80 Imports in alphabetical order 7 years ago
  Alexandre Aubin ebf5a652f0 Removing uncesseray add_arguments 7 years ago
  Alexandre Aubin 3521e2044c Adding parenthesis for print 7 years ago
  Alexandre Aubin da53aec59f Moving imports at beginning of file 7 years ago
  Alexandre Aubin 90cefd8eb8 camelCase -> snake_case 7 years ago
  Alexandre Aubin 284f2367a0 Move doc at beginning of script 7 years ago
  Alexandre Aubin 63ab94b5e5 Remove unused method 7 years ago
  Alexandre Aubin 23b7177fcf Small simplification 7 years ago
  Alexandre Aubin 644c440225 Only show paiements after an invoice is validated 7 years ago
  Alexandre Aubin 1e0f0cf770 Traduction fr des titres liés aux paiements dans l'admin 7 years ago
  Alexandre Aubin a9272beb7a Unmatch some payment containing specific keywords 7 years ago
  Alexandre Aubin 73caf47162 Fixing encoding error for payment label in logging 7 years ago
  Alexandre Aubin 71d3368c90 Properly manage case where a payment is added to an invoice specifically 7 years ago
  Alexandre Aubin bc0b56626d Moving tests at the end of model 7 years ago
  Alexandre Aubin d27848bbc3 From payment, update accounting only if created or non allocated 7 years ago
  Alexandre Aubin f53f353980 Remove debug allowed host 7 years ago
  Alexandre Aubin 72890851d3 Trying to clarify reconciliation algorithm 7 years ago
  Alexandre Aubin e6d8bcd9f1 Moar specific logs when creating/updating payments/invoices 7 years ago
  Alexandre Aubin 8a03be5567 Fixing how paiments are added in CSV import script 7 years ago
  Alexandre Aubin eeb070e2dd Misc fixes in script to import payment from CSV 7 years ago
  Alexandre Aubin 37df84300a Moving accounting log to /var/log/coin/ 7 years ago
  Alexandre Aubin 6a6e394df8 [wip] Reminder for unpaid bills/invoices 7 years ago
  Alexandre Aubin a3367348bf Tweaking admin interface for payments 7 years ago
  Alexandre Aubin 120fa32e5f Adding logging for billing 7 years ago
  Alexandre Aubin 9aec57dc11 Working CSV import script 8 years ago
  Alexandre Aubin de274e34e7 Draft of import function with payment-member matching 8 years ago
  Alexandre Aubin fd9f283030 Displaying payment and balance in member interface 8 years ago
  Alexandre Aubin 17510e9b7d Working prototype of automatic payment/invoice reconciliation 8 years ago
  Alexandre Aubin ad644779f1 Separating bills from payment 8 years ago

+ 24 - 0
README.md

@@ -202,6 +202,10 @@ You should run this command in a cron job every day.
 `python manage.py offer_subscriptions_count`: Returns subscription count grouped
 by offer type.
 
+`python manage.py import_payments_from_csv`: Import a CSV from a bank and match
+payments with services and/or members. At the moment, this is quite specific to
+ARN workflow
+
 Configuration
 =============
 
@@ -339,6 +343,26 @@ List of available settings in your `settings_local.py` file.
 - `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
 - `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
 
+Accounting logs
+---------------
+
+To log 'accounting-related operations' (creation/update of invoice, payment
+and member balance) to a specific file, add the following to settings_local.py :
+
+```
+from settings_base import *
+LOGGING["formatters"]["verbose"] = {'format': "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}
+LOGGING["handlers"]["coin_accounting"] = {
+    'level':'INFO',
+    'class':'logging.handlers.RotatingFileHandler',
+    'formatter': 'verbose',
+    'filename': '/var/log/coin/accounting.log',
+    'maxBytes': 1024*1024*15, # 15MB
+    'backupCount': 10,
+}
+LOGGING["loggers"]["coin.billing"]["handlers"] = [ 'coin_accounting' ]
+```
+
 More information
 ================
 

+ 75 - 6
coin/billing/admin.py

@@ -8,7 +8,7 @@ from django.conf.urls import url
 from django.contrib.admin.utils import flatten_fieldsets
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
-from coin.billing.models import Invoice, InvoiceDetail, Payment
+from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation
 from coin.billing.utils import get_invoice_from_id_or_number
 from django.core.urlresolvers import reverse
 import autocomplete_light
@@ -64,14 +64,36 @@ class InvoiceDetailInlineReadOnly(admin.StackedInline):
         return result
 
 
-class PaymentInline(admin.StackedInline):
+class PaymentAllocatedReadOnly(admin.TabularInline):
+    model = PaymentAllocation
+    extra = 0
+    fields = ("payment", "amount")
+    readonly_fields = ("payment", "amount")
+    verbose_name = None
+    verbose_name_plural = "Paiement alloués"
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+class PaymentInlineAdd(admin.StackedInline):
     model = Payment
     extra = 0
     fields = (('date', 'payment_mean', 'amount'),)
+    can_delete = False
+
+    verbose_name_plural = "Ajouter des paiements"
+
+    def has_change_permission(self, request):
+        return False
 
 
 class InvoiceAdmin(admin.ModelAdmin):
-    list_display = ('number', 'date', 'status', 'amount', 'member', 'validated')
+    list_display = ('number', 'date', 'status', 'amount', 'member',
+                    'validated')
     list_display_links = ('number', 'date')
     fields = (('number', 'date', 'status'),
               ('date_due'),
@@ -111,7 +133,10 @@ class InvoiceAdmin(admin.ModelAdmin):
             else:
                 inlines = [InvoiceDetailInline]
 
-            inlines += [PaymentInline]
+            if obj.validated:
+                inlines += [PaymentAllocatedReadOnly]
+                if obj.status == "open":
+                    inlines += [PaymentInlineAdd]
 
         for inline_class in inlines:
             inline = inline_class(self.model, self.admin_site)
@@ -144,11 +169,16 @@ class InvoiceAdmin(admin.ModelAdmin):
         Vue appelée lorsque l'admin souhaite valider une facture et
         générer son pdf
         """
+
         # TODO : Add better perm here
         if request.user.is_superuser:
             invoice = get_invoice_from_id_or_number(id)
-            invoice.validate()
-            messages.success(request, 'La facture a été validée.')
+            if invoice.amount() == 0:
+                messages.error(request, 'Une facture validée ne peut pas avoir'
+                                        ' un total de 0€.')
+            else:
+                invoice.validate()
+                messages.success(request, 'La facture a été validée.')
         else:
             messages.error(
                 request, 'Vous n\'avez pas l\'autorisation de valider '
@@ -158,4 +188,43 @@ class InvoiceAdmin(admin.ModelAdmin):
                                             args=(id,)))
 
 
+class PaymentAllocationInlineReadOnly(admin.TabularInline):
+    model = PaymentAllocation
+    extra = 0
+    fields = ("invoice", "amount")
+    readonly_fields = ("invoice", "amount")
+    verbose_name = None
+    verbose_name_plural = "Alloué à"
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+class PaymentAdmin(admin.ModelAdmin):
+
+    list_display = ('__unicode__', 'member', 'payment_mean', 'amount', 'date',
+                    'amount_already_allocated', 'label')
+    list_display_links = ()
+    fields = (('member'),
+              ('amount', 'payment_mean', 'date', 'label'),
+              ('amount_already_allocated'))
+    readonly_fields = ('amount_already_allocated', 'label')
+    form = autocomplete_light.modelform_factory(Payment, fields='__all__')
+
+    def get_readonly_fields(self, request, obj=None):
+
+        # If payment already started to be allocated or already have a member
+        if obj and (obj.amount_already_allocated != 0 or obj.member != None):
+            # All fields are readonly
+            return flatten_fieldsets(self.declared_fieldsets)
+        else:
+            return self.readonly_fields
+
+    def get_inline_instances(self, request, obj=None):
+        return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
+
 admin.site.register(Invoice, InvoiceAdmin)
+admin.site.register(Payment, PaymentAdmin)

+ 338 - 0
coin/billing/management/commands/import_payments_from_csv.py

@@ -0,0 +1,338 @@
+# -*- coding: utf-8 -*-
+"""
+Import payments from a CSV file from a bank.  The payments will automatically be
+parsed, and there'll be an attempt to automatically match payments with members.
+
+The matching is performed using the label of the payment.
+- First, try to find a string such as 'ID-42' where 42 is the member's ID
+- Second (if no ID found), try to find a member username (with no ambiguity with
+  respect to other usernames)
+- Third (if no username found), try to find a member family name (with no
+  ambiguity with respect to other family name)
+
+This script will check if a payment has already been registered with same
+properies (date, label, price) to avoid creating duplicate payments inside coin.
+
+By default, only a dry-run is perfomed to let you see what will happen ! You
+should run this command with --commit if you agree with the dry-run.
+"""
+
+from __future__ import unicode_literals
+
+# Standard python libs
+import csv
+import datetime
+import json
+import logging
+import os
+import re
+
+# Django specific imports
+from argparse import RawTextHelpFormatter
+from django.core.management.base import BaseCommand, CommandError
+
+# Coin specific imports
+from coin.members.models import Member
+from coin.billing.models import Payment
+
+# Parser / import / matcher configuration
+
+# The CSV delimiter
+DELIMITER=str(';')
+# The date format in the CSV
+DATE_FORMAT="%d/%m/%Y"
+# The default regex used to match the label of a payment with a member ID
+ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
+# If the label of the payment contains one of these, the payment won't be
+# matched to a member when importing it.
+KEYWORDS_TO_NOTMATCH=[ "DON", "MECENAT", "REM CHQ" ]
+
+class Command(BaseCommand):
+
+    help = __doc__
+
+    def create_parser(self, *args, **kwargs):
+        parser = super(Command, self).create_parser(*args, **kwargs)
+        parser.formatter_class = RawTextHelpFormatter
+        return parser
+
+    def add_arguments(self, parser):
+
+        parser.add_argument(
+            'filename',
+            type=str,
+            help="The CSV filename to be parsed"
+        )
+
+        parser.add_argument(
+            '--commit',
+            action='store_true',
+            dest='commit',
+            default=False,
+            help='Agree with the proposed change and commit them'
+        )
+
+
+    def handle(self, *args, **options):
+
+        assert options["filename"] != ""
+
+        if not os.path.isfile(options["filename"]):
+            raise CommandError("This file does not exists.")
+
+        payments = self.convert_csv_to_dicts(self.clean_csv(self.load_csv(options["filename"])))
+
+        payments = self.try_to_match_payment_with_members(payments)
+        new_payments = self.filter_already_known_payments(payments)
+        new_payments = self.unmatch_payment_with_keywords(new_payments)
+
+        number_of_already_known_payments = len(payments)-len(new_payments)
+        number_of_new_payments = len(new_payments)
+
+        if (number_of_new_payments > 0) :
+            print("======================================================")
+            print("   > New payments found")
+            print(json.dumps(new_payments, indent=4, separators=(',', ': ')))
+        print("======================================================")
+        print("Number of already known payments found : " + str(number_of_already_known_payments))
+        print("Number of new payments found           : " + str(number_of_new_payments))
+        print("Number of new payments matched         : " + str(len([p for p in new_payments if     p["member_matched"]])))
+        print("Number of payments not matched         : " + str(len([p for p in new_payments if not p["member_matched"]])))
+        print("======================================================")
+
+        if number_of_new_payments == 0:
+            print("Nothing to do, everything looks up to date !")
+            return
+
+        if not options["commit"]:
+            print("Please carefully review the matches, then if everything \n" \
+                  "looks alright, use --commit to register these new payments.")
+        else:
+            self.add_new_payments(new_payments)
+
+
+    def is_date(self, text):
+        try:
+            datetime.datetime.strptime(text, DATE_FORMAT)
+            return True
+        except ValueError:
+            return False
+
+
+    def is_money_amount(self, text):
+        try:
+            float(text.replace(",","."))
+            return True
+        except ValueError:
+            return False
+
+
+    def load_csv(self, filename):
+        with open(filename, "r") as f:
+            return list(csv.reader(f, delimiter=DELIMITER))
+
+
+    def clean_csv(self, data):
+
+        output = []
+
+        for i, row in enumerate(data):
+
+            for j in range(len(row)):
+                row[j] = row[j].decode('utf-8')
+
+            if len(row) < 4:
+                continue
+
+            if not self.is_date(row[0]):
+                logging.warning("Ignoring the following row (bad format for date in the first column) :")
+                logging.warning(str(row))
+                continue
+
+            if self.is_money_amount(row[2]):
+                logging.warning("Ignoring row %s (not a payment)" % str(i))
+                logging.warning(str(row))
+                continue
+
+            if not self.is_money_amount(row[3]):
+                logging.warning("Ignoring the following row (bad format for money amount in colun three) :")
+                logging.warning(str(row))
+                continue
+
+            # Clean the date
+            row[0] = datetime.datetime.strptime(row[0], DATE_FORMAT).strftime("%Y-%m-%d")
+
+            # Clean the label ...
+            row[4] = row[4].replace('\r', ' ')
+            row[4] = row[4].replace('\n', ' ')
+
+            output.append(row)
+
+        return output
+
+
+    def convert_csv_to_dicts(self, data):
+
+        output = []
+
+        for row in data:
+            payment = {}
+
+            payment["date"] = row[0]
+            payment["label"] = row[4]
+            payment["amount"] = float(row[3].replace(",","."))
+
+            output.append(payment)
+
+        return output
+
+
+    def try_to_match_payment_with_members(self, payments):
+
+        members = Member.objects.filter(status="member")
+
+        idregex = re.compile(ID_REGEX)
+
+        for payment in payments:
+
+            payment_label = payment["label"]
+
+            # First, attempt to match the member ID
+            idmatches = idregex.findall(payment_label)
+            if len(idmatches) == 1:
+                i = int(idmatches[0][1])
+                member_matches = [ member.username for member in members if member.pk==i ]
+                if len(member_matches) == 1:
+                    payment["member_matched"] = member_matches[0]
+                    #print("Matched by ID to "+member_matches[0])
+                    continue
+
+
+            # Second, attempt to find the username
+            usernamematch = None
+            for member in members:
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(member.username)+r"(\b|_)") \
+                            .findall(payment_label)
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+                # If we already had a match, abort the whole search because we
+                # have multiple usernames matched !
+                if usernamematch != None:
+                    usernamematch = None
+                    break
+
+                usernamematch = member.username
+
+            if usernamematch != None:
+                payment["member_matched"] = usernamematch
+                #print("Matched by username to "+usernamematch)
+                continue
+
+
+            # Third, attempt to match by family name
+            familynamematch = None
+            for member in members:
+                if member.last_name == "":
+                    continue
+
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(str(member.last_name))+r"(\b|_)") \
+                            .findall(payment_label)
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+                # If this familyname was matched several time, abort the whole search
+                if len(matches) > 1:
+                    familynamematch = None
+                    break
+                # If we already had a match, abort the whole search because we
+                # have multiple familynames matched !
+                if familynamematch != None:
+                    familynamematch = None
+                    break
+
+                familynamematch = str(member.last_name)
+                usernamematch = str(member.username)
+
+            if familynamematch != None:
+                payment["member_matched"] = usernamematch
+                #print("Matched by familyname to "+familynamematch)
+                continue
+
+            #print("Could not match")
+            payment["member_matched"] = None
+
+        return payments
+
+
+    def unmatch_payment_with_keywords(self, payments):
+
+        matchers = {}
+        for keyword in KEYWORDS_TO_NOTMATCH:
+            matchers[keyword] = re.compile(r"(?i)(\b|_|-)"+re.escape(keyword)+r"(\b|_|-)")
+
+        for i, payment in enumerate(payments):
+
+            # If no match found, don't filter anyway
+            if payment["member_matched"] == None:
+                continue
+
+            for keyword, matcher in matchers.items():
+                matches = matcher.findall(payment["label"])
+
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+
+                print("Ignoring possible match for payment '%s' because " \
+                      "it contains the keyword %s"                        \
+                      % (payment["label"], keyword))
+                payments[i]["member_matched"] = None
+
+                break
+
+        return payments
+
+    def filter_already_known_payments(self, payments):
+
+        new_payments = []
+
+        known_payments = Payment.objects.all()
+
+        for payment in payments:
+
+            found_match = False
+            for known_payment in known_payments:
+
+                if  (str(known_payment.date) == payment["date"].encode('utf-8')) \
+                and (known_payment.label == payment["label"]) \
+                and (float(known_payment.amount) == float(payment["amount"])):
+                    found_match = True
+                    break
+
+            if not found_match:
+                new_payments.append(payment)
+
+        return new_payments
+
+
+    def add_new_payments(self, new_payments):
+
+        for new_payment in new_payments:
+
+            # Get the member if there's a member matched
+            member = None
+            if new_payment["member_matched"]:
+                member = Member.objects.filter(username=new_payment["member_matched"])
+                assert len(member) == 1
+                member = member[0]
+
+            print("Adding new payment : ")
+            print(new_payment)
+
+            # Create the payment
+            payment = Payment.objects.create(amount=float(new_payment["amount"]),
+                                             label=new_payment["label"],
+                                             date=new_payment["date"],
+                                             member=member)
+

+ 37 - 0
coin/billing/management/commands/send_reminders_for_unpaid_bills.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+# Standard python libs
+import logging
+
+# Django specific imports
+from argparse import RawTextHelpFormatter
+from django.core.management.base import BaseCommand, CommandError
+
+# Coin specific imports
+from coin.billing.models import Invoice
+
+
+class Command(BaseCommand):
+
+    help = """
+Send a reminder to members for invoices which are due and not paid since a few
+weeks.
+"""
+
+    def create_parser(self, *args, **kwargs):
+        parser = super(Command, self).create_parser(*args, **kwargs)
+        parser.formatter_class = RawTextHelpFormatter
+        return parser
+
+    def handle(self, *args, **options):
+
+        invoices = Invoice.objects.filter(status="open")
+
+        for invoice in invoices:
+
+            if not invoice.reminder_needed():
+                continue
+
+            invoice.send_reminder(auto=True)
+

+ 54 - 0
coin/billing/migrations/0009_new_billing_system_schema.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('billing', '0008_auto_20170802_2021'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PaymentAllocation',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('amount', models.DecimalField(null=True, verbose_name='montant', max_digits=5, decimal_places=2)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='date_last_reminder_email',
+            field=models.DateTimeField(null=True, verbose_name='Date du dernier email de relance envoy\xe9', blank=True),
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='label',
+            field=models.CharField(default='', max_length=500, null=True, verbose_name='libell\xe9', blank=True),
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='member',
+            field=models.ForeignKey(related_name='payments', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to=settings.AUTH_USER_MODEL, null=True, verbose_name='membre'),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='invoice',
+            field=models.ForeignKey(related_name='payments', verbose_name='facture associ\xe9e', blank=True, to='billing.Invoice', null=True),
+        ),
+        migrations.AddField(
+            model_name='paymentallocation',
+            name='invoice',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Invoice'),
+        ),
+        migrations.AddField(
+            model_name='paymentallocation',
+            name='payment',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Payment'),
+        ),
+    ]

+ 72 - 0
coin/billing/migrations/0010_new_billing_system_data.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import sys
+
+from django.db import migrations
+
+from coin.members.models import Member
+from coin.billing.models import Invoice, InvoiceDetail, Payment
+
+
+def check_current_state(apps, schema_editor):
+
+    for invoice in Invoice.objects.all():
+
+        invoice_name = invoice.__unicode__()
+
+        related_payments = invoice.payments.all()
+
+        total_related_payments = sum([p.amount for p in related_payments])
+
+        if total_related_payments > invoice.amount:
+            error = "For invoice, current sum of payment is higher than total of invoice. Please fix this before running this migration" % invoice_name
+            raise AssertionError(error.encode('utf-8'))
+
+        if total_related_payments != 0 and not invoice.validated:
+            error = "Invoice %s is not validated but already has allocated payments. Please remove them before running this migration" % invoice_name
+            raise AssertionError(error.encode('utf-8'))
+
+
+def forwards(apps, schema_editor):
+
+    # Create allocation for all payment to their respective invoice
+    for payment in Payment.objects.all():
+        payment.member = payment.invoice.member
+        payment.allocate_to_invoice(payment.invoice)
+
+    # Update balance for all members
+    for member in Member.objects.all():
+
+        this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+        this_member_payments = [p for p in member.payments.order_by("date")]
+
+        member.balance = compute_balance(this_member_invoices,
+                                         this_member_payments)
+        member.save()
+
+
+def compute_balance(invoices, payments):
+
+    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]
+
+    s = 0
+    s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
+    s += sum([p.amount_not_allocated()    for p in active_payments])
+
+    return s
+
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0009_new_billing_system_schema'),
+        ('members', '0016_merge'),
+    ]
+
+    operations = [
+        migrations.RunPython(check_current_state),
+        migrations.RunPython(forwards),
+    ]

+ 347 - 26
coin/billing/models.py

@@ -2,25 +2,31 @@
 from __future__ import unicode_literals
 
 import datetime
-import random
+import logging
 import uuid
-import os
 import re
 from decimal import Decimal
+from dateutil.relativedelta import relativedelta
 
 from django.conf import settings
 from django.db import models, transaction
-from django.db.models.signals import post_save
-from django.dispatch import receiver
+from django.utils import timezone
 from django.utils.encoding import python_2_unicode_compatible
-
+from django.dispatch import receiver
+from django.db.models.signals import post_save, post_delete
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
 
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
 from coin.utils import private_files_storage, start_of_month, end_of_month, \
-                       disable_for_loaddata, postgresql_regexp
+                       postgresql_regexp, send_templated_email,             \
+                       disable_for_loaddata
 from coin.isp_database.context_processors import branding
+from coin.isp_database.models import ISPInfo
+
+accounting_log = logging.getLogger("coin.billing")
 
 
 def invoice_pdf_filename(instance, filename):
@@ -30,6 +36,7 @@ def invoice_pdf_filename(instance, filename):
                                       instance.number,
                                       uuid.uuid4())
 
+
 @python_2_unicode_compatible
 class InvoiceNumber:
     """ Logic and validation of invoice numbers
@@ -106,6 +113,7 @@ class InvoiceQuerySet(models.QuerySet):
         return self.filter(number__regex=postgresql_regexp(
             InvoiceNumber.RE_INVOICE_NUMBER))
 
+
 class Invoice(models.Model):
 
     INVOICES_STATUS_CHOICES = (
@@ -139,6 +147,9 @@ class Invoice(models.Model):
                            null=True, blank=True,
                            verbose_name='PDF')
 
+    date_last_reminder_email = models.DateTimeField(null=True, blank=True,
+                        verbose_name="Date du dernier email de relance envoyé")
+
     def save(self, *args, **kwargs):
         # First save to get a PK
         super(Invoice, self).save(*args, **kwargs)
@@ -167,13 +178,9 @@ class Invoice(models.Model):
 
     def amount_paid(self):
         """
-        Calcul le montant payé de la facture en fonction des éléments
-        de paiements
+        Calcul le montant déjà payé à partir des allocations de paiements
         """
-        total = Decimal('0.0')
-        for payment in self.payments.all():
-            total += payment.amount
-        return total.quantize(Decimal('0.01'))
+        return sum([a.amount for a in self.allocations.all()])
     amount_paid.short_description = 'Montant payé'
 
     def amount_remaining_to_pay(self):
@@ -207,12 +214,20 @@ class Invoice(models.Model):
         self.date = datetime.date.today()
         if not self.date_due:
             self.date_due = self.date + datetime.timedelta(days=settings.PAYMENT_DELAY)
+        old_number = self.number
         self.number = Invoice.objects.get_next_invoice_number(self.date)
+
         self.validated = True
         self.save()
         self.generate_pdf()
-        
+
+        accounting_log.info("Draft invoice %s validated as invoice %s. "
+                            "(Total amount : %f ; Member : %s)"
+                            % (old_number, self.number, self.amount(), self.member))
         assert self.pdf_exists()
+        if self.member is not None:
+            update_accounting_for_member(self.member)
+
 
     def pdf_exists(self):
         return (self.validated
@@ -220,17 +235,76 @@ class Invoice(models.Model):
                 and private_files_storage.exists(self.pdf.name))
 
     def get_absolute_url(self):
-        from django.core.urlresolvers import reverse
         return reverse('billing:invoice', args=[self.number])
 
     def __unicode__(self):
         return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
 
+    def reminder_needed(self):
+
+        # If there's no member, there's nobody to be reminded
+        if self.member is None:
+            return False
+
+        # If bill is close or not validated yet, nope
+        if self.status != 'open' or not self.validated:
+            return False
+
+        # If bill is not at least one month old, nope
+        if self.date_due >= timezone.now()+relativedelta(weeks=-4):
+            return False
+
+        # If a reminder has been recently sent, nope
+        if (self.date_last_reminder_email
+            and (self.date_last_reminder_email
+                 >= timezone.now() + relativedelta(weeks=-3))):
+            return False
+
+        return True
+
+    def send_reminder(self, auto=False):
+        """ Envoie un courrier pour rappeler à un abonné qu'une facture est
+        en attente de paiement
+
+        :param bill: id of the bill to remind
+        :param auto: is it an auto email? (changes slightly template content)
+        """
+
+        if not self.reminder_needed():
+            return False
+
+        accounting_log.info("Sending reminder email to %s to pay invoice %s"
+                           % (str(self.member), str(self.number)))
+
+        isp_info = ISPInfo.objects.first()
+        kwargs = {}
+        # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
+        if isp_info and isp_info.administrative_email:
+            kwargs['from_email'] = isp_info.administrative_email
+
+        # Si le dernier courriel de relance a été envoyé il y a moins de trois
+        # semaines, n'envoi pas un nouveau courriel
+        send_templated_email(
+            to=self.member.email,
+            subject_template='billing/emails/reminder_for_unpaid_bill.txt',
+            body_template='billing/emails/reminder_for_unpaid_bill.html',
+            context={'member': self.member, 'branding': isp_info,
+                     'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
+                     'today': datetime.date.today,
+                     'auto_sent': auto},
+            **kwargs)
+
+        # Sauvegarde en base la date du dernier envoi de mail de relance
+        self.date_last_reminder_email = timezone.now()
+        self.save()
+        return True
+
     class Meta:
         verbose_name = 'facture'
 
     objects = InvoiceQuerySet().as_manager()
 
+
 class InvoiceDetail(models.Model):
 
     label = models.CharField(max_length=100)
@@ -281,6 +355,11 @@ class Payment(models.Model):
         ('other', 'Autre')
     )
 
+    member = models.ForeignKey(Member, null=True, blank=True, default=None,
+                               related_name='payments',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
+
     payment_mean = models.CharField(max_length=100, null=True,
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
@@ -288,24 +367,266 @@ class Payment(models.Model):
     amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture',
-                                related_name='payments')
+    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
+                                blank=True, related_name='payments')
+
+    label = models.CharField(max_length=500,
+                             null=True, blank=True, default="",
+                             verbose_name='libellé')
+
+    def save(self, *args, **kwargs):
+
+        # Only if no amount already allocated...
+        if self.amount_already_allocated() == 0:
+
+            # If there's a linked invoice and no member defined
+            if self.invoice and not self.member:
+                # Automatically set member to invoice's member
+                self.member = self.invoice.member
+
+        super(Payment, self).save(*args, **kwargs)
+
+
+    def clean(self):
+
+        # Only if no amount already alloca ted...
+        if self.amount_already_allocated() == 0:
+
+            # If there's a linked invoice and this payment would pay more than
+            # the remaining amount needed to pay the invoice...
+            if self.invoice and self.amount > self.invoice.amount_remaining_to_pay():
+                raise ValidationError("This payment would pay more than the invoice's remaining to pay")
+
+    def amount_already_allocated(self):
+        return sum([ a.amount for a in self.allocations.all() ])
+
+    def amount_not_allocated(self):
+        return self.amount - self.amount_already_allocated()
+
+    @transaction.atomic
+    def allocate_to_invoice(self, invoice):
+
+        # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
+        # ...
+
+        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)
+
+        accounting_log.info("Allocating %f from payment %s to invoice %s"
+                            % (float(amount_to_allocate), str(self.date),
+                               invoice.number))
+
+        PaymentAllocation.objects.create(invoice=invoice,
+                                         payment=self,
+                                         amount=amount_to_allocate)
+
+        # Close invoice if relevant
+        if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
+            accounting_log.info("Invoice %s has been paid and is now closed"
+                                % invoice.number)
+            invoice.status = "closed"
+
+        invoice.save()
+        self.save()
 
     def __unicode__(self):
-        return 'Paiment de %0.2f€' % self.amount
+        if self.member is not None:
+            return 'Paiment de %0.2f€ le %s par %s' \
+                    % (self.amount, str(self.date), self.member)
+        else:
+            return 'Paiment de %0.2f€ le %s' \
+                    % (self.amount, str(self.date))
 
     class Meta:
         verbose_name = 'paiement'
 
 
-@receiver(post_save, sender=Payment)
-@disable_for_loaddata
-def set_invoice_as_paid_if_needed(sender, instance, **kwargs):
+# This corresponds to a (possibly partial) allocation of a given payment to
+# a given invoice.
+# E.g. consider an invoice I with total 15€ and a payment P with 10€.
+# There can be for example an allocation of 3.14€ from P to I.
+class PaymentAllocation(models.Model):
+
+    invoice = models.ForeignKey(Invoice, verbose_name='facture associée',
+                                null=False, blank=False,
+                                related_name='allocations')
+    payment = models.ForeignKey(Payment, verbose_name='facture associée',
+                                null=False, blank=False,
+                                related_name='allocations')
+    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
+                                 verbose_name='montant')
+
+
+def get_active_payment_and_invoices(member):
+
+    # Fetch relevant and active payments / invoices
+    # and sort then by chronological order : olders first, newers last.
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
+    # conflict / trouble invoices)
+
+    active_payments = [p for p in this_member_payments if p.amount_not_allocated()    > 0]
+    active_invoices = [p for p in this_member_invoices if p.amount_remaining_to_pay() > 0]
+
+    return active_payments, active_invoices
+
+
+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.amount_paid() >= instance.invoice.amount() and
-            instance.invoice.status == 'open'):
-        instance.invoice.status = 'closed'
-        instance.invoice.save()
+
+    accounting_log.info("Updating accounting for member %s ..."
+                        % str(member))
+    accounting_log.info("Member %s current balance is %f ..."
+                        % (str(member), float(member.balance)))
+
+    reconcile_invoices_and_payments(member)
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    member.balance = compute_balance(this_member_invoices,
+                                     this_member_payments)
+    member.save()
+
+    accounting_log.info("Member %s new balance is %f"
+                        % (str(member),  float(member.balance)))
+
+
+def reconcile_invoices_and_payments(member):
+    """
+    Rapproche des factures et des paiements qui sont actifs (paiement non alloué
+    ou factures non entièrement payées) automatiquement.
+    """
+
+    active_payments, active_invoices = get_active_payment_and_invoices(member)
+
+    if active_payments == []:
+        accounting_log.info("(No active payment for %s. No invoice/payment "
+                            "reconciliation needed.)."
+                            % str(member))
+        return
+    elif active_invoices == []:
+        accounting_log.info("(No active invoice for %s. No invoice/payment "
+                            "reconciliation needed.)."
+                            % str(member))
+        return
+
+    accounting_log.info("Initiating reconciliation between "
+                        "invoice and payments for %s" % str(member))
+
+    while active_payments != [] and active_invoices != []:
+
+        # Only consider the oldest active payment and the oldest active invoice
+        p = active_payments[0]
+
+        # If this payment is to be allocated for a specific invoice...
+        if p.invoice:
+            # Assert that the invoice is still 'active'
+            assert p.invoice in active_invoices
+            i = p.invoice
+            accounting_log.info("Payment is to be allocated specifically to " \
+                                "invoice %s" % str(i.number))
+        else:
+            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)
+
+        active_payments, active_invoices = get_active_payment_and_invoices(member)
+
+    if active_payments == []:
+        accounting_log.info("No more active payment. Nothing to reconcile anymore.")
+    elif active_invoices == []:
+        accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
+    return
+
+
+def compute_balance(invoices, payments):
+
+    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]
+
+    s = 0
+    s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
+    s += sum([p.amount_not_allocated()    for p in active_payments])
+
+    return s
+
+
+@receiver(post_save, sender=Payment)
+@disable_for_loaddata
+def payment_changed(sender, instance, created, **kwargs):
+
+    if created:
+        accounting_log.info("Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+                            % (instance.pk, instance.date, instance.member,
+                                instance.amount, instance.label))
+    else:
+        accounting_log.info("Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
+                            % (instance.pk, instance.date, instance.member,
+                                instance.amount, instance.label,
+                                instance.amount_already_allocated()))
+
+    # If this payment is related to a member, update the accounting for
+    # this member
+    if (created or instance.amount_not_allocated() != 0) \
+    and (instance.member is not None):
+        update_accounting_for_member(instance.member)
+
+
+@receiver(post_save, sender=Invoice)
+@disable_for_loaddata
+def invoice_changed(sender, instance, created, **kwargs):
+
+    if created:
+        accounting_log.info("Creating draft invoice %s (Member: %s)."
+                            % ('DRAFT-{}'.format(instance.pk), instance.member))
+    else:
+        if not instance.validated:
+            accounting_log.info("Updating draft invoice %s (Member: %s)."
+                    % (instance.number, instance.member))
+        else:
+            accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                    % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
+
+@receiver(post_delete, sender=PaymentAllocation)
+def paymentallocation_deleted(sender, instance, **kwargs):
+
+    invoice = instance.invoice
+
+    # Reopen invoice if relevant
+    if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
+        accounting_log.info("Reopening invoice %s ..." % invoice.number)
+        invoice.status = "open"
+        invoice.save()
+
+
+@receiver(post_delete, sender=Payment)
+def payment_deleted(sender, instance, **kwargs):
+
+    accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+                        % (instance.pk, instance.date, instance.member,
+                            instance.amount, instance.label))
+
+    member = instance.member
+
+    if member is None:
+        return
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    member.balance = compute_balance(this_member_invoices,
+                                     this_member_payments)
+    member.save()
+
+

+ 60 - 6
coin/billing/tests.py

@@ -9,7 +9,7 @@ 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, InvoiceQuerySet
+from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment
 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
@@ -114,7 +114,7 @@ class BillingInvoiceCreationTests(TestCase):
                          datetime.date(2014, 4, 1))
         self.assertEqual(invoice_test_2.details.first().period_to,
                          datetime.date(2014, 5, 31))
-                         
+
     def test_invoice_amount(self):
         invoice = Invoice(member=self.member)
         invoice.save()
@@ -132,7 +132,7 @@ class BillingInvoiceCreationTests(TestCase):
                                period_from=datetime.date(2014, 6, 1),
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
-        
+
         self.assertEqual(invoice.amount(), 111)
 
     def test_invoice_partial_payment(self):
@@ -145,10 +145,26 @@ class BillingInvoiceCreationTests(TestCase):
                                period_from=datetime.date(2014, 1, 1),
                                period_to=datetime.date(2014, 3, 31),
                                tax=0)
+        invoice.validate()
+        invoice.save()
+
         self.assertEqual(invoice.status, 'open')
-        invoice.payments.create(payment_mean='cash', amount=10)
+        p1 = Payment.objects.create(member=self.member,
+                                    invoice=invoice,
+                                    payment_mean='cash',
+                                    amount=10)
+        p1.save()
+
+        invoice = Invoice.objects.get(pk=invoice.pk)
         self.assertEqual(invoice.status, 'open')
-        invoice.payments.create(payment_mean='cash', amount=90)
+
+        p2 = Payment.objects.create(member=self.member,
+                                    invoice=invoice,
+                                    payment_mean='cash',
+                                    amount=90)
+        p2.save()
+
+        invoice = Invoice.objects.get(pk=invoice.pk)
         self.assertEqual(invoice.status, 'closed')
 
     def test_invoice_amount_before_tax(self):
@@ -168,7 +184,7 @@ class BillingInvoiceCreationTests(TestCase):
                                period_from=datetime.date(2014, 6, 1),
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
-        
+
         self.assertEqual(invoice.amount_before_tax(), 110)
 
     def test_non_billable_offer_isnt_charged(self):
@@ -320,3 +336,41 @@ class InvoiceQuerySetTests(TestCase):
             bill.validate()
             self.assertEqual(bill.date, datetime.date(2017, 1, 1))
             self.assertEqual(bill.number, '2017-01-000001')
+
+
+class PaymentInvoiceAutoReconciliationTests(TestCase):
+
+    def test_accounting_update(self):
+
+        johndoe =  Member.objects.create(username=MemberTestsUtils.get_random_username(),
+                                         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)
+
+        invoice.delete()
+        invoice2.delete()
+        payment.delete()
+        johndoe.delete()
+
+

+ 5 - 2
coin/members/admin.py

@@ -115,7 +115,9 @@ class MemberAdmin(UserAdmin):
                     'type',
                     ('first_name', 'last_name', 'nickname'),
                     'organization_name',
-                    'comments')}),
+                    'comments',
+                    'balance' # XXX we shouldn't need this, the default value should be used
+                )}),
                 coord_fieldset,
                 auth_fieldset,
                 perm_fieldset,
@@ -128,7 +130,8 @@ class MemberAdmin(UserAdmin):
                     'type',
                     ('first_name', 'last_name', 'nickname'),
                     'organization_name',
-                    'comments')}),
+                    'comments',
+                    'balance')}),
                 coord_fieldset,
                 auth_fieldset,
                 perm_fieldset

+ 1 - 1
coin/members/management/commands/members_email.py

@@ -48,7 +48,7 @@ class Command(BaseCommand):
                 Q(offer=offer)
                 # Check if OfferSubscription isn't resigned
                 & (Q(resign_date__isnull=True) | Q(resign_date__gt=today))
-            ).selec_related('member')
+            ).select_related('member')
             members = [s.member for s in offer_subscriptions]
         else:
             members = Member.objects.filter(status='member')

+ 19 - 0
coin/members/migrations/0014_member_balance.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0013_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='member',
+            name='balance',
+            field=models.DecimalField(default=0, verbose_name='account balance', max_digits=5, decimal_places=2),
+        ),
+    ]

+ 15 - 0
coin/members/migrations/0016_merge.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0015_auto_20170824_2308'),
+        ('members', '0014_member_balance'),
+    ]
+
+    operations = [
+    ]

+ 15 - 0
coin/members/migrations/0017_merge.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0016_rowlevelpermission'),
+        ('members', '0016_merge'),
+    ]
+
+    operations = [
+    ]

+ 2 - 1
coin/members/models.py

@@ -90,10 +90,11 @@ class Member(CoinLdapSyncMixin, AbstractUser):
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
-
     send_membership_fees_email = models.BooleanField(
         default=True, verbose_name='relance de cotisation',
         help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
+    balance = models.DecimalField(max_digits=5, decimal_places=2, default=0,
+                                  verbose_name='account balance')
 
     objects = MemberManager()
 

+ 28 - 0
coin/members/templates/members/invoices.html

@@ -1,6 +1,9 @@
 {% extends "base.html" %}
 
 {% block content %}
+
+<h2>Balance : {{ balance|floatformat }} €</h2>
+
 <h2>Mes factures</h2>
 
 <table id="member_invoices" class="full-width">
@@ -28,6 +31,31 @@
     </tbody>
 </table>
 
+
+<h2>Mes paiements</h2>
+
+<table id="member_payments" class="full-width">
+    <thead>
+        <tr>
+            <th>Date</th>
+            <th>Montant</th>
+            <th>Alloué</th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for payment in payments %}
+        <tr>
+            <td>{{ payment.date }}</td>
+            <td>{{ payment.amount }}</td>
+            <td>{{ payment.amount_already_allocated }}</td>
+        </tr>
+        {% empty %}
+        <tr class="placeholder"><td colspan="6">Aucun paiement.</td></tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+
 <h2>Coordonnées bancaires</h2>
 <div id="payment-howto" class="panel">
     {% include "billing/payment_howto.html" %}

+ 5 - 1
coin/members/views.py

@@ -55,10 +55,14 @@ def subscriptions(request):
 
 @login_required
 def invoices(request):
+    balance  = request.user.balance
     invoices = request.user.invoices.filter(validated=True).order_by('-date')
+    payments = request.user.payments.filter().order_by('-date')
 
     return render_to_response('members/invoices.html',
-                              {'invoices': invoices},
+                              {'balance' : balance, 
+                               'invoices': invoices, 
+                               'payments': payments},
                               context_instance=RequestContext(request))
 
 

+ 6 - 0
coin/settings_base.py

@@ -176,6 +176,8 @@ EXTRA_INSTALLED_APPS = tuple()
 LOGGING = {
     'version': 1,
     'disable_existing_loggers': False,
+    'formatters': {
+    },
     'filters': {
         'require_debug_false': {
             '()': 'django.utils.log.RequireDebugFalse'
@@ -201,6 +203,10 @@ LOGGING = {
             'handlers': ['console'],
             'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
         },
+        "coin.billing": {
+            'handlers': ['console'],
+            'level': 'INFO',
+        }
     }
 }
 

+ 6 - 5
hardware_provisioning/admin.py

@@ -10,7 +10,7 @@ from django.utils import timezone
 import autocomplete_light
 
 from .models import ItemType, Item, Loan, Storage
-from coin.members.admin import MemberAdmin
+import coin.members.admin
 
 
 User = get_user_model()
@@ -185,7 +185,8 @@ class LoanInline(admin.TabularInline):
     def has_delete_permission(self, request, obj=None):
         return False
 
-# Avoid to add LoanInline twice in case the file is loaded more than
-# once.
-if LoanInline not in MemberAdmin.inlines:
-    MemberAdmin.inlines.append(LoanInline)
+class MemberAdmin(coin.members.admin.MemberAdmin):
+    inlines = coin.members.admin.MemberAdmin.inlines + [LoanInline]
+
+admin.site.unregister(coin.members.admin.Member)
+admin.site.register(coin.members.admin.Member, MemberAdmin)