Browse Source

Merge remote-tracking branch 'origin/enh-donations-rebase' into arn-tmp

Alexandre Aubin 6 years ago
parent
commit
7eb3c503fe

+ 190 - 0
arn/templates/billing/bill_pdf.html

@@ -0,0 +1,190 @@
+{% load static isptags %}
+<html>
+<head>
+  <title>{{ bill.pdf_title }}</title>
+
+  <style>
+  @page {
+    margin: 0; padding: 40pt;
+  }
+
+  html {
+    box-sizing: border-box;
+  }
+  *, *:before, *:after {
+    box-sizing: inherit;
+  }
+
+  body {
+    font-size: 9pt;
+    font-family: sans-serif;
+    color: #111;
+    padding: 0;
+  }
+  a {
+    color: #111;
+    text-decoration: none;
+  }
+
+  p {
+    margin: 0;
+  }
+  p + p {
+    margin-top: 10pt;
+  }
+  table {
+    border-collapse: collapse;
+    width: 100%;
+    margin: 40pt 0;
+  }
+
+  h1 {
+    font-size: 12pt;
+  }
+
+  #header {
+    margin: 0 0 60pt 0;
+    border: none;
+  }
+
+  #header .logo {
+    height: 50pt;
+    margin: 0;
+  }
+
+  #header .header-left {
+    text-align: left;
+  }
+
+  #header .header-right {
+    text-align: right;
+  }
+
+  footer {
+    position: fixed;
+    bottom: 0;
+    width: 100%;
+  }
+
+  footer .logo {
+    height: 20pt;
+  }
+
+  #coordonnees {}
+
+  #coordonnees td {
+    width: 50%;
+    vertical-align: top;
+  }
+
+  #details {}
+
+  #details th,
+  #details td {
+    padding: 5pt;
+    border:1px solid #ddd;
+  }
+  #details th.cell--empty,
+  #details td.cell--empty {border: 0;}
+
+  /* details cell layout */
+  .cell-label {width: 70%;}
+  .cell-quantity {width: 5%;}
+  .cell-amount {width: 10%;}
+  .cell-tax {width: 5%;}
+  .cell-total {width: 15%;}
+
+  /* details cell style */
+  .cell-result {
+    font-weight: bold;
+  }
+  .cell-quantity {
+    text-align: center;
+  }
+  .cell--money,
+  .cell-tax {
+    text-align: right;
+    white-space: nowrap;
+  }
+
+  .cell-label p + p {
+    margin-top: 5pt;
+  }
+  .period {
+    color:#888;
+  }
+
+  #paiements {
+    margin-top: 40pt;
+    background-color: #f0f0f0;
+    padding: 10pt;
+    font-size: x-small;
+  }
+
+  footer {
+    font-size: xx-small;
+  }
+  .pagination {
+    float: right;
+  }
+  </style>
+</head>
+<body>
+
+  {% block header %}
+  <table id="header">
+      <tr>
+          <td class="header-left">
+              <img class="logo" src="{{ branding.logoURL }}" />
+          </td>
+          <td class="header-right">
+              <h1>{{ bill.pdf_title }}</h1>
+              <p>Date : {{ bill.date }}</p>
+          </td>
+      </tr>
+  </table>
+  {% endblock %}
+
+  {% block coordinates %}
+  <table id="coordonnees">
+    <tr>
+      <td id="coordonnees_isp">
+        <p>
+        {% multiline_isp_addr branding %}
+        </p>
+        <p>
+        <a href="mailto:{{ branding.email }}">{{ branding.email }}</a><br/>
+        <a href="{{ branding.website }}">{{ branding.website }}</a><br />
+        {{ branding.phone_number }}<br />
+        SIRET : {{ branding.registeredoffice.siret }}
+        </p>
+      </td>
+      <td id="coordonnees_client">
+        <p>
+        <strong>À l'intention de :</strong><br/>
+        {% with member=bill.member %}
+        {{ member.last_name }} {{ member.first_name }}<br />
+        {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
+        {% if member.address %}{{member.address}}<br />{% endif %}
+        {% if member.postal_code and member.city %}
+        {{ member.postal_code }} {{ member.city }}
+        {% endif %}
+        {% endwith %}
+        </p>
+      </td>
+    </tr>
+  </table>
+  {% endblock %}
+
+  {% block content %}
+  {% endblock %}
+
+  {% block footer %}
+  <footer>
+    <p class="pagination"><pdf:pagenumber>/<pdf:pagecount></p>
+    <p>{{ branding.shortname|upper }} est une association de droit local alsacien-mosellan à but non lucratif.</p>
+  </footer>
+  {% endblock %}
+
+</body>
+</html>

+ 8 - 8
arn/templates/billing/invoice_pdf.html

@@ -1,7 +1,7 @@
 {% load static isptags %}
 <html>
 <head>
-  <title>Facture N°{{ invoice.number }}</title>
+  <title>Facture N°{{ bill.number }}</title>
 
   <style>
   @page {
@@ -137,8 +137,8 @@
               <img class="logo" src="{{ branding.logoURL }}" />
           </td>
           <td class="header-right">
-              <p>Facture N°{{ invoice.number }}</p>
-              <p>Date : {{ invoice.date }}</p>
+              <p>Facture N°{{ bill.number }}</p>
+              <p>Date : {{ bill.date }}</p>
           </td>
       </tr>
   </table>
@@ -159,7 +159,7 @@
       <td id="coordonnees_client">
         <p>
         <strong>Facturé à</strong><br/>
-        {% with member=invoice.member %}
+        {% with member=bill.member %}
         {{ member.last_name }} {{ member.first_name }}<br />
         {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
         {% if member.address %}{{member.address}}<br />{% endif %}
@@ -183,7 +183,7 @@
       </tr>
     </thead>
     <tbody>
-      {% for detail in invoice.details.all %}
+      {% for detail in bill.details.all %}
       <tr>
         <td class="cell-label">
           <p>
@@ -209,19 +209,19 @@
       <tr>
         <td class="cell-result cell--empty"></td>
         <td class="result-label " colspan="3">Total HT</td>
-        <td class="cell--money ">{{ invoice.amount_before_tax }}€</td>
+        <td class="cell--money ">{{ bill.amount_before_tax }}€</td>
       </tr>
       <tr>
         <td class="cell-result cell--empty"></td>
         <td class="cell-result result-label" colspan="3">Total TTC</td>
-        <td class="cell-result result-total cell--money">{{ invoice.amount }}€</td>
+        <td class="cell-result result-total cell--money">{{ bill.amount }}€</td>
       </tr>
 
     </tbody>
   </table>
 
   <p>TVA non applicable - article 293 B du CGI</p>
-  <p>À payer sans escompte avant le {{ invoice.date_due }}.</p>
+  <p>À payer sans escompte avant le {{ bill.date_due }}.</p>
 
   <div id="paiements">
   {% include "billing/payment_howto.html" %}

+ 27 - 6
coin/billing/admin.py

@@ -10,8 +10,11 @@ from django import forms
 from django.shortcuts import render
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
-from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation
-from coin.billing.utils import get_invoice_from_id_or_number
+from coin.billing.models import Invoice, InvoiceDetail, Payment, \
+    PaymentAllocation, MembershipFee, Donation
+from coin.billing.utils import get_bill_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
 
@@ -204,8 +207,8 @@ 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:
+            invoice = get_bill_from_id_or_number(id)
+            if invoice.amount == 0:
                 messages.error(request, 'Une facture validée ne peut pas avoir'
                                         ' un total de 0€.')
             else:
@@ -223,8 +226,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é à"
 
@@ -298,6 +301,24 @@ class PaymentAdmin(admin.ModelAdmin):
             'opts': self.model._meta
             })
 
+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)

+ 38 - 0
coin/billing/membershipfee_filter.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.contrib.admin import SimpleListFilter
+import datetime
+
+
+class MembershipFeeFilter(SimpleListFilter):
+    # Human-readable title which will be displayed in the
+    # right admin sidebar just above the filter options.
+    title = 'Cotisations'
+
+    # Parameter for the filter that will be used in the URL query.
+    parameter_name = 'fee'
+
+    def lookups(self, request, model_admin):
+        """
+        Returns a list of tuples. The first element in each
+        tuple is the coded value for the option that will
+        appear in the URL query. The second element is the
+        human-readable name for the option that will appear
+        in the right sidebar.
+        """
+        return (
+            ('paidup', 'À jour de cotisation'),
+            ('late', 'En retard'),
+        )
+
+    def queryset(self, request, queryset):
+        """
+        Returns the filtered queryset based on the value
+        provided in the query string and retrievable via
+        `self.value()`.
+        """
+        if self.value() == 'paidup':
+            return queryset.filter(id__in=[i.id for i in queryset.all() if i.is_paid_up()])
+        if self.value() == 'late':
+            return queryset.filter(id__in=[i.id for i in queryset.all() if not i.is_paid_up()])

+ 1 - 1
coin/billing/migrations/0001_initial.py

@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
                 ('status', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', 'A payer'), ('closed', 'Regl\xe9e'), ('trouble', 'Litige')])),
                 ('date', models.DateField(default=datetime.date.today, null=True, verbose_name='date')),
                 ('date_due', models.DateField(default=coin.utils.end_of_month, null=True, verbose_name="date d'\xe9ch\xe9ance de paiement")),
-                ('pdf', models.FileField(storage=coin.utils.private_files_storage, upload_to=coin.billing.models.invoice_pdf_filename, null=True, verbose_name='PDF', blank=True)),
+                ('pdf', models.FileField(storage=coin.utils.private_files_storage, upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True)),
             ],
             options={
                 'verbose_name': 'facture',

+ 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',
+        ),
+    ]

+ 301 - 122
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,144 @@ from coin.isp_database.models import ISPInfo
 accounting_log = logging.getLogger("coin.billing")
 
 
-def invoice_pdf_filename(instance, filename):
-    """Nom et chemin du fichier pdf à stocker pour les factures"""
+def bill_pdf_filename(instance, filename):
+    """Nom et chemin du fichier pdf à stocker"""
     member_id = instance.member.id if instance.member else 0
-    return 'invoices/%d_%s_%s.pdf' % (member_id,
-                                      instance.number,
-                                      uuid.uuid4())
+    number = instance.number if hasattr(instance, "number") else instance.pk
+    bill_type = instance.type.lower()
+    return '%ss/%d_%s_%s.pdf' % (bill_type, member_id, number, uuid.uuid4())
 
+class Bill(models.Model):
+
+    CHILD_CLASS_NAMES = (
+        'Invoice',
+        'MembershipFee',
+        'Donation',
+    )
+
+    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')
+
+
+    def as_child(self):
+        for child_class_name in self.CHILD_CLASS_NAMES:
+          try:
+            return self.__getattribute__(child_class_name.lower())
+          except eval(child_class_name).DoesNotExist:
+            pass
+        return self
+
+    @property
+    def type(self):
+        for child_class_name in self.CHILD_CLASS_NAMES:
+            if hasattr(self, child_class_name.lower()):
+                return child_class_name
+        return self.__class__.__name__
+
+    @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 = {"bill": self}
+        context.update(branding(None))
+        pdf_file = render_as_pdf('billing/{bill_type}_pdf.html'.format(bill_type=self.type.lower()), context)
+        self.pdf.save('%s.pdf' % self.number if hasattr(self, "number") else self.pk, 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 draft bill DRAFT-{} (Member: {}).".format(
+                    self.pk, self.member))
+        else:
+            if not self.validated:
+                accounting_log.info(
+                    "Updating draft bill DRAFT-{} (Member: {}).".format(
+                        self.pk, self.member))
+            else:
+                accounting_log.info(
+                    "Updating bill {} (Member: {}, Total amount: {}, Amount paid: {}).".format(
+                        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 +249,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 +258,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 +274,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 +284,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 +293,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, custom_date=None):
@@ -241,12 +327,13 @@ 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])
+    @property
+    def pdf_title(self):
+        return "Facture N°"+self.number
 
     def __unicode__(self):
         return '#{} {:0.2f}€ {}'.format(
-            self.number, self.amount(), self.date_due)
+            self.number, self.amount, self.date_due)
 
     def reminder_needed(self):
 
@@ -308,6 +395,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'
 
@@ -317,7 +416,7 @@ class Invoice(models.Model):
 class InvoiceDetail(models.Model):
 
     label = models.CharField(max_length=100)
-    amount = models.DecimalField(max_digits=5, decimal_places=2,
+    amount = models.DecimalField(max_digits=8, decimal_places=2,
                                  verbose_name='montant')
     quantity = models.DecimalField(null=True, verbose_name='quantité',
                                    default=1.0, decimal_places=2, max_digits=4)
@@ -356,6 +455,87 @@ 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)
+
+        if not self.pdf_exists():
+            self.generate_pdf()
+
+    def clean(self):
+
+        # Only if no amount already allocated...
+        if self.pk is None and (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.")
+
+    @property
+    def pdf_title(self):
+       return "Reçu de don"
+
+
+    class Meta:
+        verbose_name = 'don'
+
+class MembershipFee(Bill):
+    _amount = models.DecimalField(null=False, max_digits=8, 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)
+
+        if not self.pdf_exists():
+            self.generate_pdf()
+
+    def clean(self):
+
+        # Only if no amount already allocated...
+        if self.pk is None and (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.")
+
+        if self.start_date is not None and self.end_date is None:
+            self.end_date = self.start_date + datetime.timedelta(364)
+
+    @property
+    def pdf_title(self):
+       return "Reçu de cotisation"
+
+    class Meta:
+        verbose_name = 'cotisation'
+
 class Payment(models.Model):
 
     PAYMENT_MEAN_CHOICES = (
@@ -375,10 +555,10 @@ class Payment(models.Model):
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
                                     verbose_name='moyen de paiement')
-    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
+    amount = models.DecimalField(max_digits=8, 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,
@@ -391,9 +571,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)
 
@@ -405,7 +585,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):
@@ -415,31 +595,31 @@ 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 {} from payment {} to invoice {}".format(
-                amount_to_allocate, self.date, invoice.number))
+            "Allocating {} from payment {} to bill {} {}".format(
+                amount_to_allocate, self.date, 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"):
+        if (bill.amount_remaining_to_pay() <= 0) and (bill.status == "open"):
             accounting_log.info(
-                "Invoice {} has been paid and is now closed".format(
-                    invoice.number))
-            invoice.status = "closed"
+                "Bill {} {} has been paid and is now closed".format(
+                    bill.reference, bill.pk))
+            bill.status = "closed"
 
-        invoice.save()
+        bill.save()
         self.save()
 
     def __unicode__(self):
@@ -460,31 +640,31 @@ 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',
                                 null=False, blank=False,
                                 related_name='allocations')
-    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
+    amount = models.DecimalField(max_digits=8, decimal_places=2, null=True,
                                  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):
@@ -499,12 +679,12 @@ def update_accounting_for_member(member):
     accounting_log.info(
         "Member {} current balance is {} ...".format(member, 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()
 
@@ -512,22 +692,22 @@ def update_accounting_for_member(member):
         member, 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 {}.".format(member)
-            + " No invoice/payment reconciliation needed.).")
+            + " No bill/payment reconciliation needed.).")
         return
-    elif active_invoices == []:
+    elif active_bills == []:
         accounting_log.info(
-            "(No active invoice for {}. No invoice/payment ".format(member) +
+            "(No active bill for {}. No bill/payment ".format(member) +
             "reconciliation needed.).")
         return
 
@@ -535,32 +715,32 @@ def reconcile_invoices_and_payments(member):
         "Initiating reconciliation between invoice and payments for {}".format(
             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 {}".format(
-                    i.number))
+                "Payment is to be allocated specifically to bill {}".format(
+                    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
 
@@ -598,35 +778,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 DRAFT-{} (Member: {}).".format(
-                instance.pk, instance.member))
-    else:
-        if not instance.validated:
-            accounting_log.info(
-                "Updating draft invoice DRAFT-{} (Member: {}).".format(
-                    instance.number, instance.member))
-        else:
-            accounting_log.info(
-                "Updating invoice {} (Member: {}, Total amount: {}, Amount paid: {}).".format(
-                    instance.number, instance.member,
-                    instance.amount(), instance.amount_paid()))
+    instance.log_change(created)
+
+@receiver(post_save, sender=MembershipFee)
+@disable_for_loaddata
+def membershipfee_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 donation_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 {} ...".format(invoice.number))
-        invoice.status = "open"
-        invoice.save()
+    if (bill.amount_remaining_to_pay() > 0) and (bill.status == "closed"):
+        accounting_log.info("Reopening bill {} ...".format(bill.number))
+        bill.status = "open"
+        bill.save()
 
 
 @receiver(post_delete, sender=Payment)
@@ -641,10 +820,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()
 

+ 1 - 1
coin/billing/templates/admin/billing/invoice/change_form.html

@@ -4,7 +4,7 @@
     {% if not original.validated %}
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
     {% elif original.validated %}
-        <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger le PDF</a></li>
+        <li><a href="{% url 'billing:bill_pdf' id=object_id %}">Télécharger le PDF</a></li>
     {% endif %}
     {{ block.super }}
 {% endblock %}

+ 175 - 0
coin/billing/templates/billing/bill_pdf.html

@@ -0,0 +1,175 @@
+{% load static isptags %}
+<html>
+<head>
+  <title>{{ bill.pdf_title }}</title>
+
+  <style>
+  @page {
+    margin: 0; padding: 40pt;
+  }
+
+  html {
+    box-sizing: border-box;
+  }
+  *, *:before, *:after {
+    box-sizing: inherit;
+  }
+
+  body {
+    font-size: 9pt;
+    font-family: sans-serif;
+    color: #111;
+    padding: 0;
+  }
+  a {
+    color: #111;
+    text-decoration: none;
+  }
+
+  p {
+    margin: 0;
+  }
+  p + p {
+    margin-top: 10pt;
+  }
+  table {
+    border-collapse: collapse;
+    width: 100%;
+    margin: 40pt 0;
+  }
+
+  h1 {
+    font-size: 12pt;
+  }
+
+  header {
+    margin: 0 0 60pt 0;
+  }
+
+  header .logo {
+    height: 35pt;
+    margin: 0 auto 20pt;
+  }
+
+  footer {
+    position: fixed;
+    bottom: 0;
+    width: 100%;
+  }
+
+  footer .logo {
+    height: 20pt;
+  }
+
+  #coordonnees {}
+
+  #coordonnees td {
+    width: 50%;
+    vertical-align: top;
+  }
+
+  #details {}
+
+  #details th,
+  #details td {
+    padding: 5pt;
+    border:1px solid #ddd;
+  }
+  #details th.cell--empty,
+  #details td.cell--empty {border: 0;}
+
+  /* details cell layout */
+  .cell-label {width: 70%;}
+  .cell-quantity {width: 5%;}
+  .cell-amount {width: 10%;}
+  .cell-tax {width: 5%;}
+  .cell-total {width: 15%;}
+
+  /* details cell style */
+  .cell-result {
+    font-weight: bold;
+  }
+  .cell-quantity {
+    text-align: center;
+  }
+  .cell--money,
+  .cell-tax {
+    text-align: right;
+    white-space: nowrap;
+  }
+
+  .cell-label p + p {
+    margin-top: 5pt;
+  }
+  .period {
+    color:#888;
+  }
+
+  #paiements {
+    margin-top: 40pt;
+    background-color: #f0f0f0;
+    padding: 10pt;
+    font-size: x-small;
+  }
+
+  footer {
+    font-size: xx-small;
+  }
+  .pagination {
+    float: right;
+  }
+  </style>
+</head>
+<body>
+
+  {% block header %}
+  <header>
+    <img class="logo" src="{{ branding.logoURL }}" />
+    <h1>{{ bill.pdf_title }}</h1>
+    <p>Le {{ bill.date }}</p>
+  </header>
+  {% endblock %}
+
+  {% block coordinates %}
+  <table id="coordonnees">
+    <tr>
+      <td id="coordonnees_isp">
+        <p>
+        {% multiline_isp_addr branding %}
+        </p>
+        <p>
+        <a href="mailto:{{ branding.email }}">{{ branding.email }}</a><br/>
+        <a href="{{ branding.website }}">{{ branding.website }}</a><br />
+        {{ branding.phone_number }}
+        </p>
+      </td>
+      <td id="coordonnees_client">
+        <p>
+        <strong>À l'intention de :</strong><br/>
+        {% with member=bill.member %}
+        {{ member.last_name }} {{ member.first_name }}<br />
+        {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
+        {% if member.address %}{{member.address}}<br />{% endif %}
+        {% if member.postal_code and member.city %}
+        {{ member.postal_code }} {{ member.city }}
+        {% endif %}
+        {% endwith %}
+        </p>
+      </td>
+    </tr>
+  </table>
+  {% endblock %}
+
+  {% block content %}
+  {% endblock %}
+
+  {% block footer %}
+  <footer>
+    <img class="logo" src="{{ branding.logoURL }}" />
+    <p class="pagination"><pdf:pagenumber>/<pdf:pagecount></p>
+    <p>{{ branding.shortname|upper }}, association loi de 1901 à but non lucratif - SIRET : {{ branding.registeredoffice.siret }}</p>
+  </footer>
+  {% endblock %}
+
+</body>
+</html>

+ 19 - 0
coin/billing/templates/billing/donation_pdf.html

@@ -0,0 +1,19 @@
+{% extends "billing/bill_pdf.html" %}
+
+{% block content %}
+  <br />
+  <br />
+  <br />
+
+  <p style="font-size: 1.2em;">
+  En date du {{ bill.date }}, l'association {{ branding.shortname|upper }} certifie avoir reçu un don d'un montant de {{ bill.amount }}€ de la part de {{ bill.member.first_name }} {{ bill.member.last_name }}.
+  </p>
+
+  <br />
+  <br />
+  <br />
+
+  <p>
+  N.B. : ce reçu n'a pas valeur de reçu fiscal et ne peut pas être utilisé pour prétendre à une réduction d'impôts.
+  </p>
+{% endblock %}

+ 10 - 10
coin/billing/templates/billing/invoice.html

@@ -5,11 +5,11 @@
 {% block content %}
 <div class="row">
     <div class="large-8 columns">
-        <h2>Facture N°{{ invoice.number }}</h2>
-        <p>Émise le {{ invoice.date }}</p>
+        <h2>Facture N°{{ bill.number }}</h2>
+        <p>Émise le {{ bill.date }}</p>
     </div>
     <div class="large-4 columns">
-        {% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
+        {% if bill.validated %}<a href="{% url 'billing:bill_pdf' id=bill.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
     </div>
 </div>
 
@@ -23,7 +23,7 @@
         </tr>
     </thead>
     <tbody>
-        {% for detail in invoice.details.all %}
+        {% for detail in bill.details.all %}
         <tr>
             <td>{{ detail.label }}
                 {% if detail.period_from and detail.period_to %}<br/><span class="period">Pour la période du {{ detail.period_from }} au {{ detail.period_to }}{% endif %}</span></td>
@@ -34,18 +34,18 @@
         {% endfor %}
         <tr class="total">
             <td class="" colspan="3">Total TTC</td>
-            <td class="total">{{ invoice.amount }}€</td>
+            <td class="total">{{ bill.amount }}€</td>
         </tr>
     </tbody>
 </table>
 
 <p>
-  Facture à payer avant le {{ invoice.date_due }}.
+  Facture à payer avant le {{ bill.date_due }}.
 </p>
 
 <h3>Règlement</h3>
 
-{% if invoice.payments.exists %}
+{% if bill.payments.exists %}
     <table id="invoice-payments" class="invoice-table full-width">
         <thead>
             <tr>
@@ -55,7 +55,7 @@
             </tr>
         </thead>
         <tbody>
-            {% for payment in invoice.payments.all %}
+            {% for payment in bill.payments.all %}
             <tr class="payment">
                 <td>{{ payment.get_payment_mean_display }}</td>
                 <td>{{ payment.date }}</td>
@@ -64,13 +64,13 @@
             {% endfor %}
             <tr class="total">
                 <td class="" colspan="2">Reste à payer</td>
-                <td class="total">{{ invoice.amount_remaining_to_pay }}€</td>
+                <td class="total">{{ bill.amount_remaining_to_pay }}€</td>
             </tr>
         </tbody>
     </table>
 {% endif %}
 
-{% if invoice.amount_remaining_to_pay > 0 %}
+{% if bill.amount_remaining_to_pay > 0 %}
     <div id="payment-howto" class="panel">
         {% include "billing/payment_howto.html" %}
     </div>

+ 19 - 182
coin/billing/templates/billing/invoice_pdf.html

@@ -1,162 +1,7 @@
-{% load static isptags %}
-<html>
-<head>
-  <title>Facture N°{{ invoice.number }}</title>
+{% extends "billing/bill_pdf.html" %}
 
-  <style>
-  @page {
-    margin: 0; padding: 40pt;
-  }
-
-  html {
-    box-sizing: border-box;
-  }
-  *, *:before, *:after {
-    box-sizing: inherit;
-  }
-
-  body {
-    font-size: 9pt;
-    font-family: sans-serif;
-    color: #111;
-    padding: 0;
-  }
-  a {
-    color: #111;
-    text-decoration: none;
-  }
-
-  p {
-    margin: 0;
-  }
-  p + p {
-    margin-top: 10pt;
-  }
-  table {
-    border-collapse: collapse;
-    width: 100%;
-    margin: 40pt 0;
-  }
-
-  h1 {
-    font-size: 12pt;
-  }
-
-  header {
-    margin: 0 0 60pt 0;
-  }
-
-  header .logo {
-    height: 35pt;
-    margin: 0 auto 20pt;
-  }
-
-  footer {
-    position: fixed;
-    bottom: 0;
-    width: 100%;
-  }
-
-  footer .logo {
-    height: 20pt;
-  }
-
-  #coordonnees {}
-
-  #coordonnees td {
-    width: 50%;
-    vertical-align: top;
-  }
-
-  #details {}
-
-  #details th,
-  #details td {
-    padding: 5pt;
-    border:1px solid #ddd;
-  }
-  #details th.cell--empty,
-  #details td.cell--empty {border: 0;}
-
-  /* details cell layout */
-  .cell-label {width: 70%;}
-  .cell-quantity {width: 5%;}
-  .cell-amount {width: 10%;}
-  .cell-tax {width: 5%;}
-  .cell-total {width: 15%;}
-
-  /* details cell style */
-  .cell-result {
-    font-weight: bold;
-  }
-  .cell-quantity {
-    text-align: center;
-  }
-  .cell--money, 
-  .cell-tax {
-    text-align: right;
-    white-space: nowrap;
-  }
-
-  .cell-label p + p {
-    margin-top: 5pt;
-  }
-  .period {
-    color:#888;
-  }
-
-  #paiements {
-    margin-top: 40pt;
-    background-color: #f0f0f0;
-    padding: 10pt;
-    font-size: x-small;
-  }
-
-  footer {
-    font-size: xx-small;
-  }
-  .pagination {
-    float: right;
-  }
-  </style>
-</head>
-<body>
-
-  <header>
-    <img class="logo" src="{{ branding.logoURL }}" />
-    <h1>Facture N°{{ invoice.number }}</h1>
-    <p>Le {{ invoice.date }}</p>
-  </header>
-
-  <table id="coordonnees">
-    <tr>
-      <td id="coordonnees_isp">
-        <p>
-        {% multiline_isp_addr branding %}
-        </p>
-        <p>
-        <a href="mailto:{{ branding.email }}">{{ branding.email }}</a><br/>
-        <a href="{{ branding.website }}">{{ branding.website }}</a><br />
-        {{ branding.phone_number }}
-        </p>
-      </td>
-      <td id="coordonnees_client">
-        <p>
-        <strong>Facturé à :</strong><br/>
-        {% with member=invoice.member %}
-        {{ member.last_name }} {{ member.first_name }}<br />
-        {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
-        {% if member.address %}{{member.address}}<br />{% endif %}
-        {% if member.postal_code and member.city %}
-        {{ member.postal_code }} {{ member.city }}
-        {% endif %}
-        {% endwith %}
-        </p>
-      </td>
-    </tr>
-  </table>
-
-  <table id="details" repeat="1">
+{% block content %}
+<table id="details" repeat="1">
     <thead>
       <tr>
         <th class="cell-label cell--empty"></th>
@@ -167,7 +12,7 @@
       </tr>
     </thead>
     <tbody>
-      {% for detail in invoice.details.all %}
+      {% for detail in bill.details.all %}
       <tr>
         <td class="cell-label">
           <p>
@@ -189,35 +34,27 @@
         <td class="cell-total cell--money">{{ detail.total }}€</td>
       </tr>
       {% endfor %}
-      
+
       <tr>
         <td class="cell-result cell--empty"></td>
         <td class="result-label " colspan="3">Total HT</td>
-        <td class="cell--money ">{{ invoice.amount_before_tax }}€</td>
+        <td class="cell--money ">{{ bill.amount_before_tax }}€</td>
       </tr>
       <tr>
         <td class="cell-result cell--empty"></td>
         <td class="cell-result result-label" colspan="3">Total TTC</td>
-        <td class="cell-result result-total cell--money">{{ invoice.amount }}€</td>
+        <td class="cell-result result-total cell--money">{{ bill.amount }}€</td>
       </tr>
-       
     </tbody>
-  </table>
-  <p>
-	TVA non applicable - article 293 B du CGI
-  </p>
-  <p>
-    Facture à payer avant le {{ invoice.date_due }}.
-  </p>
-
-  <div id="paiements">
-  {% include "billing/payment_howto.html" %}
-  </div>
-
-  <footer>
-    <img class="logo" src="{{ branding.logoURL }}" />
-    <p class="pagination"><pdf:pagenumber>/<pdf:pagecount></p>
-    <p>{{ branding.shortname|upper }}, association loi de 1901 à but non lucratif - SIRET : {{ branding.registeredoffice.siret }}</p>
-  </footer>
-</body>
-</html>
+</table>
+<p>
+TVA non applicable - article 293 B du CGI
+</p>
+<p>
+Facture à payer avant le {{ bill.date_due }}.
+</p>
+
+<div id="paiements">
+{% include "billing/payment_howto.html" %}
+</div>
+{% endblock %}

+ 11 - 0
coin/billing/templates/billing/membershipfee_pdf.html

@@ -0,0 +1,11 @@
+{% extends "billing/bill_pdf.html" %}
+
+{% block content %}
+  <br />
+  <br />
+  <br />
+
+  <p style="font-size: 1.2em;">
+  En date du {{ bill.date }}, l'association {{ branding.shortname|upper }} certifie avoir reçu et accepté une cotisation d'un montant de {{ bill.amount }}€ de la part de {{ bill.member.first_name }} {{ bill.member.last_name }}. Cette cotisation lui confère le statut de membre pour la période du {{ bill.start_date }} au {{ bill.end_date }}.
+  </p>
+{% endblock %}

+ 163 - 6
coin/billing/tests.py

@@ -9,7 +9,7 @@ from django.test import TestCase, Client, override_settings
 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
@@ -134,7 +134,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)
@@ -151,7 +151,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()
@@ -160,7 +160,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()
@@ -366,7 +366,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
                                      amount="15.0",
-                                     invoice=invoice)
+                                     bill=invoice)
         invoice.validate()
 
         # Second facture
@@ -374,7 +374,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
                                      amount="42",
-                                     invoice=invoice2)
+                                     bill=invoice2)
         invoice2.validate()
 
         # Payment
@@ -387,3 +387,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,7 +7,7 @@ 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.bill_pdf, name="bill_pdf"),
+    url(r'^bill/(?P<id>.+)$', views.bill, name="bill"),
     # url(r'^invoice/(?P<id>.+)/validate$', views.invoice_validate, name="invoice_validate"),
 )

+ 9 - 8
coin/billing/utils.py

@@ -4,23 +4,24 @@ from __future__ import unicode_literals
 from django.shortcuts import render, get_object_or_404
 from django.core.exceptions import PermissionDenied
 
-from coin.billing.models import Invoice
+from coin.billing.models import Bill, Invoice
 
 
-def get_invoice_from_id_or_number(id):
+def get_bill_from_id_or_number(id):
     """
-    Return an invoice using id as invoice id or failing as invoice number
+    Return an bill using id as bill id (or failing as invoice number)
     """
+
     try:
-        return Invoice.objects.get(pk=id)
+        return Bill.objects.get(pk=id).as_child()
     except:
         return get_object_or_404(Invoice, number=id)
 
 
-def assert_user_can_view_the_invoice(request, invoice):
+def assert_user_can_view_the_bill(request, bill):
     """
-    Raise PermissionDenied if logged user can't access given invoice
+    Raise PermissionDenied if logged user can't access given bill
     """
-    if not invoice.has_owner(request.user.username)\
+    if not bill.has_owner(request.user.username)\
        and not request.user.is_superuser:
-        raise PermissionDenied
+        raise PermissionDenied

+ 23 - 15
coin/billing/views.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.http import HttpResponse, HttpResponseRedirect
+from django.http import HttpResponse
 from django.template import RequestContext
 from django.shortcuts import render
 from django.contrib import messages
@@ -13,31 +13,39 @@ from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
 from coin.billing.utils import get_invoice_from_id_or_number, assert_user_can_view_the_invoice
 
+from sendfile import sendfile
+
+from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
+from coin.billing.utils import get_bill_from_id_or_number, assert_user_can_view_the_bill
 
-def invoice_pdf(request, id):
+
+def bill_pdf(request, id):
     """
-    Renvoi une facture générée en format pdf
-    id peut être soit la pk d'une facture, soit le numero de facture
+    Renvoi une note générée en format pdf
+    id peut être soit la pk d'une note, soit le numero d'une facture
     """
-    invoice = get_invoice_from_id_or_number(id)
+    bill = get_bill_from_id_or_number(id)
+
+    assert_user_can_view_the_bill(request, bill)
 
-    assert_user_can_view_the_invoice(request, invoice)
+    human_type = bill.__class__._meta.verbose_name
+    id_for_filename = bill.number if bill.type == "Invoice" else bill.pk
 
-    pdf_filename = 'Facture_%s.pdf' % invoice.number
+    pdf_filename = '%s_%s.pdf' % (human_type, id_for_filename)
 
-    return sendfile(request, invoice.pdf.path,
+    return sendfile(request, bill.pdf.path,
                     attachment=True, attachment_filename=pdf_filename)
 
 
-def invoice(request, id):
+def bill(request, id):
     """
-    Affiche une facture et son détail
-    id peut être soit la pk d'une facture, soit le numero de facture
+    Affiche une note et son détail
+    id peut être soit la pk d'une note, soit le numero de facture
     """
-    invoice = get_invoice_from_id_or_number(id)
+    bill = get_bill_from_id_or_number(id)
 
-    assert_user_can_view_the_invoice(request, invoice)
+    assert_user_can_view_the_bill(request, bill)
 
-    return render(request, 'billing/invoice.html', {"invoice": invoice})
+    return render(request, 'billing/%s.html' % bill.type.lower(), {"bill":
+        bill})
 
-    return response

+ 2 - 16
coin/members/admin.py

@@ -16,24 +16,16 @@ from django.core.urlresolvers import reverse
 from django.utils.safestring import mark_safe
 
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
+    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
@@ -225,7 +217,7 @@ class MemberAdmin(UserAdmin):
 
     save_on_top = True
 
-    inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
+    inlines = [CryptoKeyInline, OfferSubscriptionInline]
 
     def add_member_warnings(self, request, member):
         has_active_subscriptions = member.get_active_subscriptions().exists()
@@ -354,11 +346,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)}
@@ -366,7 +353,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',
+        ),
+    ]

+ 5 - 2
coin/members/models.py

@@ -137,10 +137,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,
+    balance = models.DecimalField(max_digits=6, decimal_places=2, default=0,
                                   verbose_name='account balance')
 
     objects = MemberManager.from_queryset(MemberQuerySet)()
@@ -181,7 +182,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"
 

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

@@ -8,12 +8,12 @@
 	<h2>Balance : {{ balance|floatformat }} €</h2>
 {% endif %}
 
-<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>
@@ -23,11 +23,11 @@
     <tbody>
         {% for invoice in invoices %}
         <tr>
-            <td><a href="{% url 'billing:invoice' id=invoice.number %}">{{ invoice.number }}</a></td>
+            <td>{% if invoice.type == "Invoice" %}<a href="{% url 'billing:bill' id=invoice.pk %}">{{ invoice.reference }}</a>{% else %}{{ invoice.reference}}{% endif %}</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>

+ 0 - 1
coin/members/tests.py

@@ -391,7 +391,6 @@ class MemberTests(TestCase):
             member.save()
 
 
-
 class MemberAdminTests(TestCase):
 
     def setUp(self):

+ 2 - 1
coin/members/views.py

@@ -6,6 +6,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):
@@ -53,7 +54,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',