Browse Source

Merge branch 'enh-donations' into arndev

ljf 7 years ago
parent
commit
2be2108c59

+ 27 - 4
coin/billing/admin.py

@@ -8,8 +8,11 @@ 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, PaymentAllocation
+from coin.billing.models import Invoice, InvoiceDetail, Payment, \
+    PaymentAllocation, MembershipFee, Donation
 from coin.billing.utils import get_invoice_from_id_or_number
+from coin.billing.membershipfee_filter import MembershipFeeFilter
+from coin.members.admin import MemberAdmin
 from django.core.urlresolvers import reverse
 import autocomplete_light
 
@@ -173,7 +176,7 @@ class InvoiceAdmin(admin.ModelAdmin):
         # TODO : Add better perm here
         if request.user.is_superuser:
             invoice = get_invoice_from_id_or_number(id)
-            if invoice.amount() == 0:
+            if invoice.amount == 0:
                 messages.error(request, 'Une facture validée ne peut pas avoir'
                                         ' un total de 0€.')
             else:
@@ -191,8 +194,8 @@ class InvoiceAdmin(admin.ModelAdmin):
 class PaymentAllocationInlineReadOnly(admin.TabularInline):
     model = PaymentAllocation
     extra = 0
-    fields = ("invoice", "amount")
-    readonly_fields = ("invoice", "amount")
+    fields = ("bill", "amount")
+    readonly_fields = ("bill", "amount")
     verbose_name = None
     verbose_name_plural = "Alloué à"
 
@@ -226,5 +229,25 @@ class PaymentAdmin(admin.ModelAdmin):
     def get_inline_instances(self, request, obj=None):
         return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
 
+
+class MembershipFeeAdmin(admin.ModelAdmin):
+    list_display = ('member', 'end_date', '_amount')
+    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
+
+
+class DonationAdmin(admin.ModelAdmin):
+    list_display = ('member', 'date', '_amount')
+    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
+
+class MembershipFeeInline(admin.TabularInline):
+    model = MembershipFee
+    extra = 0
+    fields = ('start_date', 'end_date', '_amount')
+
+MemberAdmin.list_filter += ('status', MembershipFeeFilter)
+MemberAdmin.inlines += [MembershipFeeInline]
+
 admin.site.register(Invoice, InvoiceAdmin)
 admin.site.register(Payment, PaymentAdmin)
+admin.site.register(MembershipFee, MembershipFeeAdmin)
+admin.site.register(Donation, DonationAdmin)

coin/members/membershipfee_filter.py → coin/billing/membershipfee_filter.py


+ 19 - 0
coin/billing/migrations/0011_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0010_new_billing_system_data'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='payment',
+            name='amount',
+            field=models.DecimalField(null=True, verbose_name='montant', max_digits=6, decimal_places=2),
+        ),
+    ]

+ 96 - 0
coin/billing/migrations/0012_auto_20180415_1502.py

@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import datetime
+import coin.billing.models
+import django.db.models.deletion
+import django.core.files.storage
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('billing', '0011_auto_20180414_2250'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Bill',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('status2', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', '\xc0 payer'), ('closed', 'R\xe9gl\xe9e'), ('trouble', 'Litige')])),
+                ('date2', models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans le document final', null=True, verbose_name='date')),
+                ('pdf2', models.FileField(storage=django.core.files.storage.FileSystemStorage(location='/vagrant/apps/extra/coin2/smedia/'), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True)),
+            ],
+            options={
+                'verbose_name': 'note',
+            },
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='id',
+        ),
+        migrations.AlterField(
+            model_name='invoice',
+            name='date',
+            field=models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans le document final', null=True, verbose_name='date'),
+        ),
+        migrations.AlterField(
+            model_name='invoice',
+            name='pdf',
+            field=models.FileField(storage=django.core.files.storage.FileSystemStorage(location='/vagrant/apps/extra/coin2/smedia/'), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='invoice',
+            field=models.ForeignKey(related_name='payments_old', verbose_name='facture associ\xe9e', blank=True, to='billing.Invoice', null=True),
+        ),
+        migrations.AlterField(
+            model_name='paymentallocation',
+            name='invoice',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Bill'),
+        ),
+        migrations.CreateModel(
+            name='Donation',
+            fields=[
+                ('bill_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill')),
+                ('_amount', models.DecimalField(verbose_name='Montant', max_digits=8, decimal_places=2)),
+            ],
+            options={
+                'verbose_name': 'don',
+            },
+            bases=('billing.bill',),
+        ),
+        migrations.CreateModel(
+            name='TempMembershipFee',
+            fields=[
+                ('bill_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill')),
+                ('_amount', models.DecimalField(default=None, help_text='en \u20ac', verbose_name='montant', max_digits=5, decimal_places=2)),
+                ('start_date', models.DateField(verbose_name='date de d\xe9but de cotisation')),
+                ('end_date', models.DateField(help_text='par d\xe9faut, la cotisation dure un an', verbose_name='date de fin de cotisation', blank=True)),
+            ],
+            options={
+                'verbose_name': 'cotisation',
+            },
+            bases=('billing.bill',),
+        ),
+        migrations.AddField(
+            model_name='bill',
+            name='member2',
+            field=models.ForeignKey(related_name='bills', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to=settings.AUTH_USER_MODEL, null=True, verbose_name='membre'),
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='bill_ptr',
+            field=models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, default=1, serialize=False, to='billing.Bill'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='bill',
+            field=models.ForeignKey(related_name='payments', verbose_name='facture associ\xe9e', blank=True, to='billing.Bill', null=True),
+        ),
+    ]

+ 63 - 0
coin/billing/migrations/0013_auto_20180415_0413.py

@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+def forwards(apps, schema_editor):
+
+    Payment = apps.get_model('billing', 'Payment')
+    Invoice = apps.get_model('billing', 'Invoice')
+    MembershipFee = apps.get_model('members', 'MembershipFee')
+    TempMembershipFee = apps.get_model('billing', 'TempMembershipFee')
+    PaymentAllocation = apps.get_model('billing', 'PaymentAllocation')
+
+    # Update payment
+    for payment in Payment.objects.all():
+        payment.bill = payment.invoice
+        payment.save()
+
+    # Update invoice data
+    for invoice in Invoice.objects.all():
+        invoice.member2 = invoice.member
+        invoice.status2 = invoice.status
+        invoice.date2 = invoice.date
+        invoice.pdf2 = invoice.pdf
+        invoice.save()
+
+    # Update balance for all members
+    for fee in MembershipFee.objects.all():
+
+        temp_fee = TempMembershipFee()
+        temp_fee._amount = fee.amount
+        temp_fee.start_date = fee.start_date
+        temp_fee.end_date = fee.end_date
+        temp_fee.status2 = 'closed'
+        temp_fee.date2 = temp_fee.start_date
+        temp_fee.member2 = fee.member
+        temp_fee.save()
+
+        payment = Payment()
+        payment.member = fee.member
+        payment.payment_mean = fee.payment_method
+        payment.amount = fee.amount
+        payment.date = fee.payment_date
+        payment.label = fee.reference
+        payment.bill = temp_fee
+        payment.save()
+
+        allocation = PaymentAllocation()
+        allocation.invoice = temp_fee
+        allocation.payment = payment
+        allocation.amount = fee.amount
+        allocation.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0012_auto_20180415_1502'),
+    ]
+
+    operations = [
+        migrations.RunPython(forwards),
+    ]

+ 54 - 0
coin/billing/migrations/0014_auto_20180415_1814.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0013_auto_20180415_0413'),
+    ]
+
+    operations = [
+        migrations.RenameModel(
+            old_name='TempMembershipFee',
+            new_name='MembershipFee',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='date2',
+            new_name='date',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='member2',
+            new_name='member',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='pdf2',
+            new_name='pdf',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='status2',
+            new_name='status',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='date',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='member',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='pdf',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='status',
+        ),
+    ]

+ 18 - 0
coin/billing/migrations/0015_remove_payment_invoice.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0014_auto_20180415_1814'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='payment',
+            name='invoice',
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0016_auto_20180415_2208.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0015_remove_payment_invoice'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='paymentallocation',
+            old_name='invoice',
+            new_name='bill',
+        ),
+    ]

+ 245 - 112
coin/billing/models.py

@@ -5,6 +5,7 @@ import datetime
 import logging
 import uuid
 import re
+import abc
 from decimal import Decimal
 from dateutil.relativedelta import relativedelta
 
@@ -29,13 +30,116 @@ from coin.isp_database.models import ISPInfo
 accounting_log = logging.getLogger("coin.billing")
 
 
-def invoice_pdf_filename(instance, filename):
+def bill_pdf_filename(instance, filename):
     """Nom et chemin du fichier pdf à stocker pour les factures"""
     member_id = instance.member.id if instance.member else 0
     return 'invoices/%d_%s_%s.pdf' % (member_id,
                                       instance.number,
                                       uuid.uuid4())
 
+def invoice_pdf_filename(instance, filename):
+    return bill_pdf_filename(instance, filename)
+
+class Bill(models.Model):
+
+    BILL_STATUS_CHOICES = (
+        ('open', 'À payer'),
+        ('closed', 'Réglée'),
+        ('trouble', 'Litige')
+    )
+
+    status = models.CharField(max_length=50, choices=BILL_STATUS_CHOICES,
+                              default='open',
+                              verbose_name='statut')
+    date = models.DateField(
+        default=datetime.date.today, null=True, verbose_name='date',
+        help_text='Cette date sera définie à la date de validation dans le document final')
+    member = models.ForeignKey(Member, null=True, blank=True, default=None,
+                               related_name='bills',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
+    pdf = models.FileField(storage=private_files_storage,
+                           upload_to=bill_pdf_filename,
+                           null=True, blank=True,
+                           verbose_name='PDF')
+    @property
+    def amount(self):
+        """ Return bill amount """
+        return self.cast.amount
+    amount.fget.short_description = 'Montant'
+
+    def amount_paid(self):
+        """
+        Calcul le montant déjà payé à partir des allocations de paiements
+        """
+        return sum([a.amount for a in self.allocations.all()])
+    amount_paid.short_description = 'Montant payé'
+
+    def amount_remaining_to_pay(self):
+        """
+        Calcul le montant restant à payer
+        """
+        return self.amount - self.amount_paid()
+    amount_remaining_to_pay.short_description = 'Reste à payer'
+
+    def has_owner(self, username):
+        """
+        Check if passed username (ex gmajax) is owner of the invoice
+        """
+        return (self.member and self.member.username == username)
+
+    def generate_pdf(self):
+        """
+        Make and store a pdf file for the invoice
+        """
+        context = {"invoice": self}
+        context.update(branding(None))
+        pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
+        self.pdf.save('%s.pdf' % self.number, pdf_file)
+
+    def pdf_exists(self):
+        return (bool(self.pdf)
+                and private_files_storage.exists(self.pdf.name))
+
+#    def get_absolute_url(self):
+#        return reverse('billing:invoice', args=[self.number])
+
+    def __unicode__(self):
+        return '%s - %s - %i€' % (self.member, self.date, self.amount)
+
+    @property
+    def reference(self):
+        if hasattr(self, 'membershipfee'):
+            return 'Cotisation'
+        elif hasattr(self, 'donation'):
+            return 'Don'
+        elif hasattr(self, 'invoice'):
+            return self.invoice.number
+
+    def log_change(self, created):
+
+        if created:
+            accounting_log.info("Creating %s %s (Member: %s)."
+                                % (self.pk, self.member))
+        else:
+            accounting_log.info("Updating %s %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                        % (self.pk, self.member, self.amount, self.amount_paid() ))
+
+    @property
+    def cast(bill):
+        if hasattr(bill, 'membershipfee'):
+            return bill.membershipfee
+        elif hasattr(bill, 'donation'):
+            return bill.donation
+        elif hasattr(bill, 'invoice'):
+            return bill.invoice
+    @staticmethod
+    def get_member_validated_bills(member):
+        related_fields = ['membershipfee', 'donation', 'invoice']
+        return [i.cast for i in member.bills.order_by("date") if i.cast.validated]
+
+    class Meta:
+        verbose_name = 'note'
 
 @python_2_unicode_compatible
 class InvoiceNumber:
@@ -117,13 +221,8 @@ class InvoiceQuerySet(models.QuerySet):
             InvoiceNumber.RE_INVOICE_NUMBER))
 
 
-class Invoice(models.Model):
+class Invoice(Bill):
 
-    INVOICES_STATUS_CHOICES = (
-        ('open', 'À payer'),
-        ('closed', 'Réglée'),
-        ('trouble', 'Litige')
-    )
 
     validated = models.BooleanField(default=False, verbose_name='validée',
                                     help_text='Once validated, a PDF is generated'
@@ -131,24 +230,10 @@ class Invoice(models.Model):
     number = models.CharField(max_length=25,
                               unique=True,
                               verbose_name='numéro')
-    status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
-                              default='open',
-                              verbose_name='statut')
-    date = models.DateField(
-        default=datetime.date.today, null=True, verbose_name='date',
-        help_text='Cette date sera définie à la date de validation dans la facture finale')
     date_due = models.DateField(
         null=True, blank=True,
         verbose_name="date d'échéance de paiement",
         help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY))
-    member = models.ForeignKey(Member, null=True, blank=True, default=None,
-                               related_name='invoices',
-                               verbose_name='membre',
-                               on_delete=models.SET_NULL)
-    pdf = models.FileField(storage=private_files_storage,
-                           upload_to=invoice_pdf_filename,
-                           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é")
@@ -161,6 +246,7 @@ class Invoice(models.Model):
             self.number = 'DRAFT-{}'.format(self.pk)
             self.save()
 
+    @property
     def amount(self):
         """
         Calcul le montant de la facture
@@ -170,7 +256,7 @@ class Invoice(models.Model):
         for detail in self.details.all():
             total += detail.total()
         return total.quantize(Decimal('0.01'))
-    amount.short_description = 'Montant'
+    amount.fget.short_description = 'Montant'
 
     def amount_before_tax(self):
         total = Decimal('0.0')
@@ -179,34 +265,6 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount_before_tax.short_description = 'Montant HT'
 
-    def amount_paid(self):
-        """
-        Calcul le montant déjà payé à partir des allocations de paiements
-        """
-        return sum([a.amount for a in self.allocations.all()])
-    amount_paid.short_description = 'Montant payé'
-
-    def amount_remaining_to_pay(self):
-        """
-        Calcul le montant restant à payer
-        """
-        return self.amount() - self.amount_paid()
-    amount_remaining_to_pay.short_description = 'Reste à payer'
-
-    def has_owner(self, username):
-        """
-        Check if passed username (ex gmajax) is owner of the invoice
-        """
-        return (self.member and self.member.username == username)
-
-    def generate_pdf(self):
-        """
-        Make and store a pdf file for the invoice
-        """
-        context = {"invoice": self}
-        context.update(branding(None))
-        pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
-        self.pdf.save('%s.pdf' % self.number, pdf_file)
 
     @transaction.atomic
     def validate(self):
@@ -226,7 +284,7 @@ class Invoice(models.Model):
 
         accounting_log.info("Draft invoice %s validated as invoice %s. "
                             "(Total amount : %f ; Member : %s)"
-                            % (old_number, self.number, self.amount(), self.member))
+                            % (old_number, self.number, self.amount, self.member))
         assert self.pdf_exists()
         if self.member is not None:
             update_accounting_for_member(self.member)
@@ -237,11 +295,8 @@ class Invoice(models.Model):
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
 
-    def get_absolute_url(self):
-        return reverse('billing:invoice', args=[self.number])
-
     def __unicode__(self):
-        return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
+        return '#%s %0.2f€ %s' % (self.number, self.amount, self.date_due)
 
     def reminder_needed(self):
 
@@ -302,6 +357,18 @@ class Invoice(models.Model):
         self.save()
         return True
 
+    def log_change(self, created):
+
+        if created:
+            accounting_log.info("Creating draft invoice %s (Member: %s)."
+                                % ('DRAFT-{}'.format(self.pk), self.member))
+        else:
+            if not self.validated:
+                accounting_log.info("Updating draft invoice %s (Member: %s)."
+                        % (self.number, self.member))
+            else:
+                accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                        % (self.number, self.member, self.amount, self.amount_paid() ))
     class Meta:
         verbose_name = 'facture'
 
@@ -349,6 +416,69 @@ class InvoiceDetail(models.Model):
         verbose_name = 'détail de facture'
 
 
+class Donation(Bill):
+    _amount = models.DecimalField(max_digits=8, decimal_places=2,
+                                  verbose_name='Montant')
+
+    @property
+    def amount(self):
+        return self._amount
+    amount.fget.short_description = 'Montant'
+
+    @property
+    def validated(self):
+        return True
+
+    def save(self, *args, **kwargs):
+
+        super(Donation, self).save(*args, **kwargs)
+
+    def clean(self):
+        # Only if no amount already allocated...
+        if not self.member or self.member.balance < self.amount:
+            raise ValidationError("Le solde n'est pas suffisant pour payer ce don. \
+                        Merci de commencer par enregistrer un paiement pour ce membre.")
+    class Meta:
+        verbose_name = 'don'
+
+class MembershipFee(Bill):
+    _amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
+                                 default=settings.MEMBER_DEFAULT_COTISATION,
+                                 verbose_name='montant', help_text='en €')
+    start_date = models.DateField(
+        null=False,
+        blank=False,
+        verbose_name='date de début de cotisation')
+    end_date = models.DateField(
+        null=False,
+        blank=True,
+        verbose_name='date de fin de cotisation',
+        help_text='par défaut, la cotisation dure un an')
+
+    @property
+    def amount(self):
+        return self._amount
+    amount.fget.short_description = 'Montant'
+    @property
+    def validated(self):
+        return True
+
+    def save(self, *args, **kwargs):
+
+        super(MembershipFee, self).save(*args, **kwargs)
+
+
+    def clean(self):
+        if self.start_date is not None and self.end_date is None:
+            self.end_date = self.start_date + datetime.timedelta(364)
+        # Only if no amount already allocated...
+        if not self.member or self.member.balance < self.amount:
+            raise ValidationError("Le solde n'est pas suffisant pour payer cette cotisation. \
+                        Merci de commencer par enregistrer un paiement pour ce membre.")
+
+    class Meta:
+        verbose_name = 'cotisation'
+
 class Payment(models.Model):
 
     PAYMENT_MEAN_CHOICES = (
@@ -370,7 +500,7 @@ class Payment(models.Model):
     amount = models.DecimalField(max_digits=6, decimal_places=2, null=True,
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
+    bill = models.ForeignKey(Bill, verbose_name='facture associée', null=True,
                                 blank=True, related_name='payments')
 
     label = models.CharField(max_length=500,
@@ -383,9 +513,9 @@ class Payment(models.Model):
         if self.amount_already_allocated() == 0:
 
             # If there's a linked invoice and no member defined
-            if self.invoice and not self.member:
+            if self.bill and not self.member:
                 # Automatically set member to invoice's member
-                self.member = self.invoice.member
+                self.member = self.bill.member
 
         super(Payment, self).save(*args, **kwargs)
 
@@ -397,7 +527,7 @@ class Payment(models.Model):
 
             # 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():
+            if self.bill and self.amount > self.bill.amount_remaining_to_pay():
                 raise ValidationError("This payment would pay more than the invoice's remaining to pay")
 
     def amount_already_allocated(self):
@@ -407,30 +537,30 @@ class Payment(models.Model):
         return self.amount - self.amount_already_allocated()
 
     @transaction.atomic
-    def allocate_to_invoice(self, invoice):
+    def allocate_to_bill(self, bill):
 
         # 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_pay  = bill.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"
+        accounting_log.info("Allocating %f from payment %s to bill %s %s"
                             % (float(amount_to_allocate), str(self.date),
-                               invoice.number))
+                               bill.reference, bill.pk))
 
-        PaymentAllocation.objects.create(invoice=invoice,
+        PaymentAllocation.objects.create(bill=bill,
                                          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"
+        if (bill.amount_remaining_to_pay() <= 0) and (bill.status == "open"):
+            accounting_log.info("Bill %s %s has been paid and is now closed"
+                                % (bill.reference, bill.pk))
+            bill.status = "closed"
 
-        invoice.save()
+        bill.save()
         self.save()
 
     def __unicode__(self):
@@ -451,7 +581,7 @@ class Payment(models.Model):
 # 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',
+    bill = models.ForeignKey(Bill, verbose_name='facture associée',
                                 null=False, blank=False,
                                 related_name='allocations')
     payment = models.ForeignKey(Payment, verbose_name='facture associée',
@@ -461,21 +591,21 @@ class PaymentAllocation(models.Model):
                                  verbose_name='montant')
 
 
-def get_active_payment_and_invoices(member):
+def get_active_payment_and_bills(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_bills = Bill.get_member_validated_bills(member)
     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]
+    active_bills = [p for p in this_member_bills if p.amount_remaining_to_pay() > 0]
 
-    return active_payments, active_invoices
+    return active_payments, active_bills
 
 
 def update_accounting_for_member(member):
@@ -489,12 +619,12 @@ def update_accounting_for_member(member):
     accounting_log.info("Member %s current balance is %f ..."
                         % (member, float(member.balance)))
 
-    reconcile_invoices_and_payments(member)
+    reconcile_bills_and_payments(member)
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
 
-    member.balance = compute_balance(this_member_invoices,
+    member.balance = compute_balance(this_member_bills,
                                      this_member_payments)
     member.save()
 
@@ -502,21 +632,21 @@ def update_accounting_for_member(member):
                         % (member,  float(member.balance)))
 
 
-def reconcile_invoices_and_payments(member):
+def reconcile_bills_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)
+    active_payments, active_bills = get_active_payment_and_bills(member)
 
     if active_payments == []:
-        accounting_log.info("(No active payment for %s. No invoice/payment "
+        accounting_log.info("(No active payment for %s. No bill/payment "
                             "reconciliation needed.)."
                             % member)
         return
-    elif active_invoices == []:
-        accounting_log.info("(No active invoice for %s. No invoice/payment "
+    elif active_bills == []:
+        accounting_log.info("(No active bill for %s. No bill/payment "
                             "reconciliation needed.)."
                             % member)
         return
@@ -524,31 +654,31 @@ def reconcile_invoices_and_payments(member):
     accounting_log.info("Initiating reconciliation between "
                         "invoice and payments for %s" % member)
 
-    while active_payments != [] and active_invoices != []:
+    while active_payments != [] and active_bills != []:
 
-        # Only consider the oldest active payment and the oldest active invoice
+        # Only consider the oldest active payment and the oldest active bill
         p = active_payments[0]
 
-        # If this payment is to be allocated for a specific invoice...
-        if p.invoice:
+        # If this payment is to be allocated for a specific bill...
+        if p.bill:
             # Assert that the invoice is still 'active'
-            assert p.invoice in active_invoices
-            i = p.invoice
+            assert p.bill in active_bills
+            i = p.bill
             accounting_log.info("Payment is to be allocated specifically to " \
-                                "invoice %s" % str(i.number))
+                                "bill %s" % str(i.pk))
         else:
-            i = active_invoices[0]
+            i = active_bills[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)
+        p.allocate_to_bill(i)
 
-        active_payments, active_invoices = get_active_payment_and_invoices(member)
+        active_payments, active_bills = get_active_payment_and_bills(member)
 
     if active_payments == []:
         accounting_log.info("No more active payment. Nothing to reconcile anymore.")
-    elif active_invoices == []:
+    elif active_bills == []:
         accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
     return
 
@@ -586,31 +716,34 @@ def payment_changed(sender, instance, created, **kwargs):
         update_accounting_for_member(instance.member)
 
 
-@receiver(post_save, sender=Invoice)
+@receiver(post_save, sender=Bill)
 @disable_for_loaddata
-def invoice_changed(sender, instance, created, **kwargs):
+def bill_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() ))
+    instance.log_change(created)
+
+@receiver(post_save, sender=MembershipFee)
+@disable_for_loaddata
+def fee_changed(sender, instance, created, **kwargs):
+    if created and instance.member is not None:
+        update_accounting_for_member(instance.member)
+
+@receiver(post_save, sender=Donation)
+@disable_for_loaddata
+def fee_changed(sender, instance, created, **kwargs):
+    if created and instance.member is not None:
+        update_accounting_for_member(instance.member)
 
 @receiver(post_delete, sender=PaymentAllocation)
 def paymentallocation_deleted(sender, instance, **kwargs):
 
-    invoice = instance.invoice
+    bill = instance.bill
 
     # 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()
+    if (bill.amount_remaining_to_pay() > 0) and (bill.status == "closed"):
+        accounting_log.info("Reopening bill %s ..." % bill.number)
+        bill.status = "open"
+        bill.save()
 
 
 @receiver(post_delete, sender=Payment)
@@ -625,10 +758,10 @@ def payment_deleted(sender, instance, **kwargs):
     if member is None:
         return
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
 
-    member.balance = compute_balance(this_member_invoices,
+    member.balance = compute_balance(this_member_bills,
                                      this_member_payments)
     member.save()
 

+ 163 - 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, InvoiceDetail, Payment
+from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment, MembershipFee
 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
@@ -133,7 +133,7 @@ class BillingInvoiceCreationTests(TestCase):
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
 
-        self.assertEqual(invoice.amount(), 111)
+        self.assertEqual(invoice.amount, 111)
 
     def test_invoice_partial_payment(self):
         invoice = Invoice(member=self.member)
@@ -150,7 +150,7 @@ class BillingInvoiceCreationTests(TestCase):
 
         self.assertEqual(invoice.status, 'open')
         p1 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
+                                    bill=invoice,
                                     payment_mean='cash',
                                     amount=10)
         p1.save()
@@ -159,7 +159,7 @@ class BillingInvoiceCreationTests(TestCase):
         self.assertEqual(invoice.status, 'open')
 
         p2 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
+                                    bill=invoice,
                                     payment_mean='cash',
                                     amount=90)
         p2.save()
@@ -365,7 +365,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
                                      amount="15.0",
-                                     invoice=invoice)
+                                     bill=invoice)
         invoice.validate()
 
         # Second facture
@@ -373,7 +373,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
                                      amount="42",
-                                     invoice=invoice2)
+                                     bill=invoice2)
         invoice2.validate()
 
         # Payment
@@ -386,3 +386,160 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
         johndoe.delete()
 
 
+class MembershipFeeTests(TestCase):
+    def test_mandatory_start_date(self):
+        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
+        member.save()
+
+        # If there is no start_date clean_fields() should raise an
+        # error but not clean().
+        membershipfee = MembershipFee(member=member)
+        self.assertRaises(ValidationError, membershipfee.clean_fields)
+        self.assertIsNone(membershipfee.clean())
+
+        # If there is a start_date, everything is fine.
+        membershipfee = MembershipFee(member=member, start_date=date.today())
+        self.assertIsNone(membershipfee.clean_fields())
+        self.assertIsNone(membershipfee.clean())
+
+        member.delete()
+
+    def test_member_end_date_of_memberhip(self):
+        """
+        Test que end_date_of_membership d'un membre envoi bien la date de fin d'adhésion
+        """
+        # Créer un membre
+        first_name = 'Tin'
+        last_name = 'Tin'
+        username = MemberTestsUtils.get_random_username()
+        member = Member(first_name=first_name,
+                        last_name=last_name, username=username)
+        member.save()
+
+        start_date = date.today()
+        end_date = start_date + relativedelta(years=+1)
+
+        # Créé une cotisation
+        membershipfee = MembershipFee(member=member, amount=20,
+                                      start_date=start_date,
+                                      end_date=end_date)
+        membershipfee.save()
+
+        self.assertEqual(member.end_date_of_membership(), end_date)
+
+    def test_member_is_paid_up(self):
+        """
+        Test l'état "a jour de cotisation" d'un adhérent.
+        """
+        # Créé un membre
+        first_name = 'Capitain'
+        last_name = 'Haddock'
+        username = MemberTestsUtils.get_random_username()
+        member = Member(first_name=first_name,
+                        last_name=last_name, username=username)
+        member.save()
+
+        start_date = date.today()
+        end_date = start_date + relativedelta(years=+1)
+
+        # Test qu'un membre sans cotisation n'est pas à jour
+        self.assertEqual(member.is_paid_up(), False)
+
+        # Créé une cotisation passée
+        membershipfee = MembershipFee(member=member, amount=20,
+                                      start_date=date.today() +
+                                      relativedelta(years=-1),
+                                      end_date=date.today() + relativedelta(days=-10))
+        membershipfee.save()
+        # La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
+        # être à jour de cotistion
+        self.assertEqual(member.is_paid_up(), False)
+
+        # Créé une cotisation actuelle
+        membershipfee = MembershipFee(member=member, amount=20,
+                                      start_date=date.today() +
+                                      relativedelta(days=-10),
+                                      end_date=date.today() + relativedelta(days=+10))
+        membershipfee.save()
+        # La cotisation se terminant dans 10 jour, il devrait être à jour
+        # de cotisation
+        self.assertEqual(member.is_paid_up(), True)
+
+
+class MemberTestCallForMembershipCommand(TestCase):
+
+    def setUp(self):
+        # Créé un membre
+        self.username = MemberTestsUtils.get_random_username()
+        self.member = Member(first_name='Richard', last_name='Stallman',
+                             username=self.username)
+        self.member.save()
+
+
+    def tearDown(self):
+        # Supprime le membre
+        self.member.delete()
+        MembershipFee.objects.all().delete()
+
+    def create_membership_fee(self, end_date):
+        # Créé une cotisation passée se terminant dans un mois
+        membershipfee = MembershipFee(member=self.member, amount=20,
+                                      start_date=end_date + relativedelta(years=-1),
+                                      end_date=end_date)
+        membershipfee.save()
+
+    def create_membership_fee(self, end_date):
+        # Créé une cotisation se terminant à la date indiquée
+        membershipfee = MembershipFee(member=self.member, amount=20,
+                                      start_date=end_date + relativedelta(years=-1),
+                                      end_date=end_date)
+        membershipfee.save()
+        return membershipfee
+
+    def do_test_email_sent(self, expected_emails = 1, reset_date_last_call = True):
+        # Vide la outbox
+        mail.outbox = []
+        # Call command
+        management.call_command('call_for_membership_fees', stdout=StringIO())
+        # Test
+        self.assertEqual(len(mail.outbox), expected_emails)
+        # Comme on utilise le même membre, on reset la date de dernier envoi
+        if reset_date_last_call:
+            self.member.date_last_call_for_membership_fees_email = None
+            self.member.save()
+
+    def do_test_for_a_end_date(self, end_date, expected_emails=1, reset_date_last_call = True):
+        # Supprimer toutes les cotisations (au cas ou)
+        MembershipFee.objects.all().delete()
+        # Créé la cotisation
+        membershipfee = self.create_membership_fee(end_date)
+        self.do_test_email_sent(expected_emails, reset_date_last_call)
+        membershipfee.delete()
+
+    def test_call_email_sent_at_expected_dates(self):
+        # 1 mois avant la fin, à la fin et chaque mois après la fin pendant 3 mois
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=+1))
+        self.do_test_for_a_end_date(date.today())
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=-1))
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=-2))
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=-3))
+
+    def test_call_email_not_sent_if_active_membership_fee(self):
+        # Créé une cotisation se terminant dans un mois
+        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
+        # Un mail devrait être envoyé (ne pas vider date_last_call_for_membership_fees_email)
+        self.do_test_email_sent(1, False)
+        # Créé une cotisation enchainant et se terminant dans un an
+        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1, years=+1))
+        # Pas de mail envoyé
+        self.do_test_email_sent(0)
+
+    def test_date_last_call_for_membership_fees_email(self):
+        # Créé une cotisation se terminant dans un mois
+        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
+        # Un mail envoyé (ne pas vider date_last_call_for_membership_fees_email)
+        self.do_test_email_sent(1, False)
+        # Tente un deuxième envoi, qui devrait être à 0
+        self.do_test_email_sent(0)
+
+

+ 2 - 2
coin/billing/urls.py

@@ -7,8 +7,8 @@ from coin.billing import views
 
 urlpatterns = patterns(
     '',
-    url(r'^invoice/(?P<id>.+)/pdf$', views.invoice_pdf, name="invoice_pdf"),
-    url(r'^invoice/(?P<id>.+)$', views.invoice, name="invoice"),
+    url(r'^bill/(?P<id>.+)/pdf$', views.invoice_pdf, name="bill_pdf"),
+    url(r'^bill/(?P<id>.+)$', views.invoice, name="bill"),
     # url(r'^invoice/(?P<id>.+)/validate$', views.invoice_validate, name="invoice_validate"),
 
     url('invoice/create_all_members_invoices_for_a_period', views.gen_invoices)

+ 1 - 1
coin/billing/utils.py

@@ -23,4 +23,4 @@ def assert_user_can_view_the_invoice(request, invoice):
     """
     if not invoice.has_owner(request.user.username)\
        and not request.user.is_superuser:
-        raise PermissionDenied
+        raise PermissionDenied

+ 3 - 18
coin/members/admin.py

@@ -14,25 +14,16 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
-from coin.members.membershipfee_filter import MembershipFeeFilter
+    Member, CryptoKey, LdapUser, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 import autocomplete_light
 
-
 class CryptoKeyInline(admin.StackedInline):
     model = CryptoKey
     extra = 0
 
 
-class MembershipFeeInline(admin.TabularInline):
-    model = MembershipFee
-    extra = 0
-    fields = ('start_date', 'end_date', 'amount', 'payment_method',
-              'reference', 'payment_date')
-
-
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     extra = 0
@@ -87,7 +78,7 @@ class MemberAdmin(UserAdmin):
                     'nickname', 'organization_name', 'email',
                     'end_date_of_membership')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
-    list_filter = ('status', MembershipFeeFilter)
+    list_filter = ()
     search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     ordering = ('status', 'username')
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
@@ -141,7 +132,7 @@ class MemberAdmin(UserAdmin):
 
     save_on_top = True
 
-    inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
+    inlines = [CryptoKeyInline, OfferSubscriptionInline]
 
     def get_queryset(self, request):
         qs = super(MemberAdmin, self).get_queryset(request)
@@ -256,11 +247,6 @@ class MemberAdmin(UserAdmin):
     bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
 
 
-class MembershipFeeAdmin(admin.ModelAdmin):
-    list_display = ('member', 'end_date', 'amount', 'payment_method',
-                    'payment_date')
-    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
-
 class RowLevelPermissionAdmin(admin.ModelAdmin):
     def get_changeform_initial_data(self, request):
         return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
@@ -268,7 +254,6 @@ class RowLevelPermissionAdmin(admin.ModelAdmin):
 
 
 admin.site.register(Member, MemberAdmin)
-admin.site.register(MembershipFee, MembershipFeeAdmin)
 # admin.site.unregister(Group)
 # admin.site.register(LdapUser, LdapUserAdmin)
 admin.site.register(RowLevelPermission, RowLevelPermissionAdmin)

+ 30 - 0
coin/members/migrations/0018_auto_20180414_2250.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import coin.members.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0017_merge'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='rowlevelpermission',
+            options={'verbose_name': 'permission fine', 'verbose_name_plural': 'permissions fines'},
+        ),
+        migrations.AlterModelManagers(
+            name='member',
+            managers=[
+                ('objects', coin.members.models.MemberManager()),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='balance',
+            field=models.DecimalField(default=0, verbose_name='account balance', max_digits=6, decimal_places=2),
+        ),
+    ]

+ 21 - 0
coin/members/migrations/0019_auto_20180415_1814.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0018_auto_20180414_2250'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='membershipfee',
+            name='member',
+        ),
+        migrations.DeleteModel(
+            name='MembershipFee',
+        ),
+    ]

+ 3 - 45
coin/members/models.py

@@ -153,7 +153,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
     # Renvoie la date de fin de la dernière cotisation du membre
     def end_date_of_membership(self):
-        aggregate = self.membership_fees.aggregate(end=Max('end_date'))
+        # Avoid import loop
+        from coin.billing.models import MembershipFee
+        aggregate = MembershipFee.objects.filter(member=self).aggregate(end=Max('end_date'))
         return aggregate['end']
     end_date_of_membership.short_description = "Date de fin d'adhésion"
 
@@ -438,50 +440,6 @@ class CryptoKey(CoinLdapSyncMixin, models.Model):
         verbose_name = 'clé'
 
 
-class MembershipFee(models.Model):
-    PAYMENT_METHOD_CHOICES = (
-        ('cash', 'Espèces'),
-        ('check', 'Chèque'),
-        ('transfer', 'Virement'),
-        ('other', 'Autre')
-    )
-
-    member = models.ForeignKey('Member', related_name='membership_fees',
-                               verbose_name='membre')
-    amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
-                                 default=settings.MEMBER_DEFAULT_COTISATION,
-                                 verbose_name='montant', help_text='en €')
-    start_date = models.DateField(
-        null=False,
-        blank=False,
-        verbose_name='date de début de cotisation')
-    end_date = models.DateField(
-        null=False,
-        blank=True,
-        verbose_name='date de fin de cotisation',
-        help_text='par défaut, la cotisation dure un an')
-
-    payment_method = models.CharField(max_length=100, null=True, blank=True,
-                                      choices=PAYMENT_METHOD_CHOICES,
-                                      verbose_name='moyen de paiement')
-    reference = models.CharField(max_length=125, null=True, blank=True,
-                                 verbose_name='référence du paiement',
-                                 help_text='numéro de chèque, '
-                                 'référence de virement, commentaire...')
-    payment_date = models.DateField(null=True, blank=True,
-                                    verbose_name='date du paiement')
-
-    def clean(self):
-        if self.start_date is not None and self.end_date is None:
-            self.end_date = self.start_date + datetime.timedelta(364)
-
-    def __unicode__(self):
-        return '%s - %s - %i€' % (self.member, self.start_date, self.amount)
-
-    class Meta:
-        verbose_name = 'cotisation'
-
-
 class LdapUser(ldapdb.models.Model):
     # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
     base_dn = settings.LDAP_USER_BASE_DN

+ 4 - 4
coin/members/templates/members/invoices.html

@@ -4,12 +4,12 @@
 
 <h2>Balance : {{ balance|floatformat }} €</h2>
 
-<h2>Mes factures</h2>
+<h2>Mes factures et reçus</h2>
 
 <table id="member_invoices" class="full-width">
     <thead>
         <tr>
-            <th>Numéro</th>
+            <th>Référence</th>
             <th>Date</th>
             <th>Montant</th>
             <th>Reste à payer</th>
@@ -19,11 +19,11 @@
     <tbody>
         {% for invoice in invoices %}
         <tr>
-            <td><a href="{% url 'billing:invoice' id=invoice.number %}">{{ invoice.number }}</a></td>
+            <td><a href="{% url 'billing:bill' id=invoice.pk %}">{{ invoice.reference }}</a></td>
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.amount }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
-            <td>{% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
+            <td>{% if invoice.validated %}<a href="{% url 'billing:bill_pdf' id=invoice.pk %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
         </tr>
         {% empty %}
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>

+ 1 - 158
coin/members/tests.py

@@ -16,7 +16,7 @@ from django.contrib.auth.models import User
 from django.core import mail, management
 from django.core.exceptions import ValidationError
 
-from coin.members.models import Member, MembershipFee, LdapUser
+from coin.members.models import Member, LdapUser
 from coin.validation import chatroom_url_validator
 
 
@@ -298,67 +298,6 @@ class MemberTests(TestCase):
 
         member.delete()
 
-    def test_member_end_date_of_memberhip(self):
-        """
-        Test que end_date_of_membership d'un membre envoi bien la date de fin d'adhésion
-        """
-        # Créer un membre
-        first_name = 'Tin'
-        last_name = 'Tin'
-        username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
-        member.save()
-
-        start_date = date.today()
-        end_date = start_date + relativedelta(years=+1)
-
-        # Créé une cotisation
-        membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=start_date,
-                                      end_date=end_date)
-        membershipfee.save()
-
-        self.assertEqual(member.end_date_of_membership(), end_date)
-
-    def test_member_is_paid_up(self):
-        """
-        Test l'état "a jour de cotisation" d'un adhérent.
-        """
-        # Créé un membre
-        first_name = 'Capitain'
-        last_name = 'Haddock'
-        username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
-        member.save()
-
-        start_date = date.today()
-        end_date = start_date + relativedelta(years=+1)
-
-        # Test qu'un membre sans cotisation n'est pas à jour
-        self.assertEqual(member.is_paid_up(), False)
-
-        # Créé une cotisation passée
-        membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() +
-                                      relativedelta(years=-1),
-                                      end_date=date.today() + relativedelta(days=-10))
-        membershipfee.save()
-        # La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
-        # être à jour de cotistion
-        self.assertEqual(member.is_paid_up(), False)
-
-        # Créé une cotisation actuelle
-        membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() +
-                                      relativedelta(days=-10),
-                                      end_date=date.today() + relativedelta(days=+10))
-        membershipfee.save()
-        # La cotisation se terminant dans 10 jour, il devrait être à jour
-        # de cotisation
-        self.assertEqual(member.is_paid_up(), True)
-
     def test_member_cant_be_created_without_names(self):
         """
         Test qu'un membre ne peut pas être créé sans "noms"
@@ -374,7 +313,6 @@ class MemberTests(TestCase):
             member.save()
 
 
-
 class MemberAdminTests(TestCase):
 
     def setUp(self):
@@ -417,83 +355,6 @@ class MemberAdminTests(TestCase):
         member.delete()
 
 
-class MemberTestCallForMembershipCommand(TestCase):
-
-    def setUp(self):
-        # Créé un membre
-        self.username = MemberTestsUtils.get_random_username()
-        self.member = Member(first_name='Richard', last_name='Stallman',
-                             username=self.username)
-        self.member.save()
-
-
-    def tearDown(self):
-        # Supprime le membre
-        self.member.delete()
-        MembershipFee.objects.all().delete()
-
-    def create_membership_fee(self, end_date):
-        # Créé une cotisation passée se terminant dans un mois
-        membershipfee = MembershipFee(member=self.member, amount=20,
-                                      start_date=end_date + relativedelta(years=-1),
-                                      end_date=end_date)
-        membershipfee.save()
-
-    def create_membership_fee(self, end_date):
-        # Créé une cotisation se terminant à la date indiquée
-        membershipfee = MembershipFee(member=self.member, amount=20,
-                                      start_date=end_date + relativedelta(years=-1),
-                                      end_date=end_date)
-        membershipfee.save()
-        return membershipfee
-
-    def do_test_email_sent(self, expected_emails = 1, reset_date_last_call = True):
-        # Vide la outbox
-        mail.outbox = []
-        # Call command
-        management.call_command('call_for_membership_fees', stdout=StringIO())
-        # Test
-        self.assertEqual(len(mail.outbox), expected_emails)
-        # Comme on utilise le même membre, on reset la date de dernier envoi
-        if reset_date_last_call:
-            self.member.date_last_call_for_membership_fees_email = None
-            self.member.save()
-
-    def do_test_for_a_end_date(self, end_date, expected_emails=1, reset_date_last_call = True):
-        # Supprimer toutes les cotisations (au cas ou)
-        MembershipFee.objects.all().delete()
-        # Créé la cotisation
-        membershipfee = self.create_membership_fee(end_date)
-        self.do_test_email_sent(expected_emails, reset_date_last_call)
-        membershipfee.delete()
-
-    def test_call_email_sent_at_expected_dates(self):
-        # 1 mois avant la fin, à la fin et chaque mois après la fin pendant 3 mois
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=+1))
-        self.do_test_for_a_end_date(date.today())
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=-1))
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=-2))
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=-3))
-
-    def test_call_email_not_sent_if_active_membership_fee(self):
-        # Créé une cotisation se terminant dans un mois
-        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
-        # Un mail devrait être envoyé (ne pas vider date_last_call_for_membership_fees_email)
-        self.do_test_email_sent(1, False)
-        # Créé une cotisation enchainant et se terminant dans un an
-        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1, years=+1))
-        # Pas de mail envoyé
-        self.do_test_email_sent(0)
-
-    def test_date_last_call_for_membership_fees_email(self):
-        # Créé une cotisation se terminant dans un mois
-        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
-        # Un mail envoyé (ne pas vider date_last_call_for_membership_fees_email)
-        self.do_test_email_sent(1, False)
-        # Tente un deuxième envoi, qui devrait être à 0
-        self.do_test_email_sent(0)
-
-
 class MemberTestsUtils(object):
 
     @staticmethod
@@ -510,21 +371,3 @@ class TestValidators(TestCase):
         with self.assertRaises(ValidationError):
             chatroom_url_validator('http://#faimaison@irc.geeknode.org')
 
-
-class MembershipFeeTests(TestCase):
-    def test_mandatory_start_date(self):
-        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
-        member.save()
-
-        # If there is no start_date clean_fields() should raise an
-        # error but not clean().
-        membershipfee = MembershipFee(member=member)
-        self.assertRaises(ValidationError, membershipfee.clean_fields)
-        self.assertIsNone(membershipfee.clean())
-
-        # If there is a start_date, everything is fine.
-        membershipfee = MembershipFee(member=member, start_date=date.today())
-        self.assertIsNone(membershipfee.clean_fields())
-        self.assertIsNone(membershipfee.clean())
-
-        member.delete()

+ 2 - 1
coin/members/views.py

@@ -5,6 +5,7 @@ from django.shortcuts import render
 from django.contrib.auth.decorators import login_required
 from django.conf import settings
 from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
+from coin.billing.models import Bill
 
 @login_required
 def index(request):
@@ -52,7 +53,7 @@ def subscriptions(request):
 @login_required
 def invoices(request):
     balance  = request.user.balance
-    invoices = request.user.invoices.filter(validated=True).order_by('-date')
+    invoices = Bill.get_member_validated_bills(request.user)
     payments = request.user.payments.filter().order_by('-date')
 
     return render(request, 'members/invoices.html',

+ 19 - 0
housing/migrations/0002_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('housing', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='housingconfiguration',
+            name='activated',
+            field=models.BooleanField(default=True, verbose_name='activ\xe9'),
+        ),
+    ]

+ 19 - 0
vpn/migrations/0004_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vpn', '0003_merge'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vpnconfiguration',
+            name='activated',
+            field=models.BooleanField(default=True, verbose_name='activ\xe9'),
+        ),
+    ]

+ 19 - 0
vps/migrations/0004_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vps', '0003_auto_20170803_0411'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vpsconfiguration',
+            name='activated',
+            field=models.BooleanField(default=True, verbose_name='activ\xe9'),
+        ),
+    ]