#181 Gère les reçus de dons & de cotisation en plus des factures

Open
ljf wants to merge 16 commits from ARN/enh-donations-rebase into FFDN/master

+ 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>

+ 235 - 0
arn/templates/billing/invoice_pdf.html

@@ -0,0 +1,235 @@
+{% load static isptags %}
+<html>
+<head>
+  <title>Facture N°{{ bill.number }}</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>
+
+  <table id="header">
+      <tr>
+          <td class="header-left">
+              <img class="logo" src="{{ branding.logoURL }}" />
+          </td>
+          <td class="header-right">
+              <p>Facture N°{{ bill.number }}</p>
+              <p>Date : {{ bill.date }}</p>
+          </td>
+      </tr>
+  </table>
+
+  <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>Facturé à</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>
+
+  <table id="details" repeat="1">
+    <thead>
+      <tr>
+        <th class="cell-label cell--empty"></th>
+        <th class="cell-quantity">Quantité</th>
+        <th class="cell-amount cell--money">PU (HT)</th>
+        <th class="cell-label cell-tax">TVA</th>
+        <th class="cell-total cell--money">Total</th>
+      </tr>
+    </thead>
+    <tbody>
+      {% for detail in bill.details.all %}
+      <tr>
+        <td class="cell-label">
+          <p>
+          {{ detail.label }}
+          {% if detail.offersubscription %}
+            <br/>
+            <span class="subscription">{{ detail.offersubscription.offer.name }}
+            {% if detail.offersubscription.offer.reference %} ({{ detail.offersubscription.get_subscription_reference }}){% endif %}
+            </span>
+          {% endif %}
+          </p>
+          {% if detail.period_from and detail.period_to %}
+          <p class="period">Pour la période du {{ detail.period_from }} au {{ detail.period_to }}</p>
+          {% endif %}
+        </td>
+        <td class="cell-quantity">{{ detail.quantity }}</td>
+        <td class="cell-amount cell--money">{{ detail.amount }}€</td>
+        <td class="cell-tax">{{ detail.tax }}%</td>
+        <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 ">{{ 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">{{ bill.amount }}€</td>
+      </tr>
+
+    </tbody>
+  </table>
+
+  <p>TVA non applicable - article 293 B du CGI</p>
+  <p>À payer sans escompte avant le {{ bill.date_due }}.</p>
+
+  <div id="paiements">
+  {% include "billing/payment_howto.html" %}
+  </div>
+
+  <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>
+</body>
+</html>

+ 29 - 6
coin/billing/admin.py

@@ -9,8 +9,11 @@ from django.contrib.admin.utils import flatten_fieldsets
 from django import forms
 from django import forms
 
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
 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
 from django.core.urlresolvers import reverse
 import autocomplete_light
 import autocomplete_light
 
 
@@ -184,8 +187,8 @@ class InvoiceAdmin(admin.ModelAdmin):
 
 
         # TODO : Add better perm here
         # TODO : Add better perm here
         if request.user.is_superuser:
         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'
                 messages.error(request, 'Une facture validée ne peut pas avoir'
                                         ' un total de 0€.')
                                         ' un total de 0€.')
             else:
             else:
@@ -203,8 +206,8 @@ class InvoiceAdmin(admin.ModelAdmin):
 class PaymentAllocationInlineReadOnly(admin.TabularInline):
 class PaymentAllocationInlineReadOnly(admin.TabularInline):
     model = PaymentAllocation
     model = PaymentAllocation
     extra = 0
     extra = 0
-    fields = ("invoice", "amount")
-    readonly_fields = ("invoice", "amount")
+    fields = ("bill", "amount")
+    readonly_fields = ("bill", "amount")
     verbose_name = None
     verbose_name = None
     verbose_name_plural = "Alloué à"
     verbose_name_plural = "Alloué à"
 
 
@@ -238,5 +241,25 @@ class PaymentAdmin(admin.ModelAdmin):
     def get_inline_instances(self, request, obj=None):
     def get_inline_instances(self, request, obj=None):
         return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
         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(Invoice, InvoiceAdmin)
 admin.site.register(Payment, PaymentAdmin)
 admin.site.register(Payment, PaymentAdmin)
+admin.site.register(MembershipFee, MembershipFeeAdmin)
+admin.site.register(Donation, DonationAdmin)

+ 2 - 6
coin/members/membershipfee_filter.py

@@ -33,10 +33,6 @@ class MembershipFeeFilter(SimpleListFilter):
         `self.value()`.
         `self.value()`.
         """
         """
         if self.value() == 'paidup':
         if self.value() == 'paidup':
-            return queryset.filter(
-                membership_fees__start_date__lte=datetime.date.today,
-                membership_fees__end_date__gte=datetime.date.today)
+            return queryset.filter(id__in=[i.id for i in queryset.all() if i.is_paid_up()])
         if self.value() == 'late':
         if self.value() == 'late':
-            return queryset.filter(status='member').exclude(
-                membership_fees__start_date__lte=datetime.date.today,
-                membership_fees__end_date__gte=datetime.date.today)
+            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')])),
                 ('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', 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")),
                 ('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={
             options={
                 'verbose_name': 'facture',
                 '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 logging
 import uuid
 import uuid
 import re
 import re
+import abc
 from decimal import Decimal
 from decimal import Decimal
 from dateutil.relativedelta import relativedelta
 from dateutil.relativedelta import relativedelta
 
 
@@ -29,13 +30,144 @@ from coin.isp_database.models import ISPInfo
 accounting_log = logging.getLogger("coin.billing")
 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
     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
 @python_2_unicode_compatible
 class InvoiceNumber:
 class InvoiceNumber:
@@ -117,13 +249,8 @@ class InvoiceQuerySet(models.QuerySet):
             InvoiceNumber.RE_INVOICE_NUMBER))
             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',
     validated = models.BooleanField(default=False, verbose_name='validée',
                                     help_text='Once validated, a PDF is generated'
                                     help_text='Once validated, a PDF is generated'
@@ -131,24 +258,10 @@ class Invoice(models.Model):
     number = models.CharField(max_length=25,
     number = models.CharField(max_length=25,
                               unique=True,
                               unique=True,
                               verbose_name='numéro')
                               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(
     date_due = models.DateField(
         null=True, blank=True,
         null=True, blank=True,
         verbose_name="date d'échéance de paiement",
         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))
         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,
     date_last_reminder_email = models.DateTimeField(null=True, blank=True,
                         verbose_name="Date du dernier email de relance envoyé")
                         verbose_name="Date du dernier email de relance envoyé")
@@ -161,6 +274,7 @@ class Invoice(models.Model):
             self.number = 'DRAFT-{}'.format(self.pk)
             self.number = 'DRAFT-{}'.format(self.pk)
             self.save()
             self.save()
 
 
+    @property
     def amount(self):
     def amount(self):
         """
         """
         Calcul le montant de la facture
         Calcul le montant de la facture
@@ -170,7 +284,7 @@ class Invoice(models.Model):
         for detail in self.details.all():
         for detail in self.details.all():
             total += detail.total()
             total += detail.total()
         return total.quantize(Decimal('0.01'))
         return total.quantize(Decimal('0.01'))
-    amount.short_description = 'Montant'
+    amount.fget.short_description = 'Montant'
 
 
     def amount_before_tax(self):
     def amount_before_tax(self):
         total = Decimal('0.0')
         total = Decimal('0.0')
@@ -179,34 +293,6 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
         return total.quantize(Decimal('0.01'))
     amount_before_tax.short_description = 'Montant HT'
     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
     @transaction.atomic
     def validate(self):
     def validate(self):
@@ -239,12 +325,13 @@ class Invoice(models.Model):
                 and bool(self.pdf)
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
                 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):
     def __unicode__(self):
         return '#{} {:0.2f}€ {}'.format(
         return '#{} {:0.2f}€ {}'.format(
-            self.number, self.amount(), self.date_due)
+            self.number, self.amount, self.date_due)
 
 
     def reminder_needed(self):
     def reminder_needed(self):
 
 
@@ -306,6 +393,18 @@ class Invoice(models.Model):
         self.save()
         self.save()
         return True
         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:
     class Meta:
         verbose_name = 'facture'
         verbose_name = 'facture'
 
 
@@ -315,7 +414,7 @@ class Invoice(models.Model):
 class InvoiceDetail(models.Model):
 class InvoiceDetail(models.Model):
 
 
     label = models.CharField(max_length=100)
     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')
                                  verbose_name='montant')
     quantity = models.DecimalField(null=True, verbose_name='quantité',
     quantity = models.DecimalField(null=True, verbose_name='quantité',
                                    default=1.0, decimal_places=2, max_digits=4)
                                    default=1.0, decimal_places=2, max_digits=4)
@@ -353,6 +452,87 @@ class InvoiceDetail(models.Model):
         verbose_name = 'détail de facture'
         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):
 class Payment(models.Model):
 
 
     PAYMENT_MEAN_CHOICES = (
     PAYMENT_MEAN_CHOICES = (
@@ -371,10 +551,10 @@ class Payment(models.Model):
                                     default='transfer',
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
                                     choices=PAYMENT_MEAN_CHOICES,
                                     verbose_name='moyen de paiement')
                                     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')
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
     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')
                                 blank=True, related_name='payments')
 
 
     label = models.CharField(max_length=500,
     label = models.CharField(max_length=500,
@@ -387,9 +567,9 @@ class Payment(models.Model):
         if self.amount_already_allocated() == 0:
         if self.amount_already_allocated() == 0:
 
 
             # If there's a linked invoice and no member defined
             # 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
                 # Automatically set member to invoice's member
-                self.member = self.invoice.member
+                self.member = self.bill.member
 
 
         super(Payment, self).save(*args, **kwargs)
         super(Payment, self).save(*args, **kwargs)
 
 
@@ -401,7 +581,7 @@ class Payment(models.Model):
 
 
             # If there's a linked invoice and this payment would pay more than
             # If there's a linked invoice and this payment would pay more than
             # the remaining amount needed to pay the invoice...
             # 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")
                 raise ValidationError("This payment would pay more than the invoice's remaining to pay")
 
 
     def amount_already_allocated(self):
     def amount_already_allocated(self):
@@ -411,31 +591,31 @@ class Payment(models.Model):
         return self.amount - self.amount_already_allocated()
         return self.amount - self.amount_already_allocated()
 
 
     @transaction.atomic
     @transaction.atomic
-    def allocate_to_invoice(self, invoice):
+    def allocate_to_bill(self, bill):
 
 
         # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
         # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
         # ...
         # ...
 
 
         amount_can_pay = self.amount_not_allocated()
         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)
         amount_to_allocate = min(amount_can_pay, amount_to_pay)
 
 
         accounting_log.info(
         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,
                                          payment=self,
                                          amount=amount_to_allocate)
                                          amount=amount_to_allocate)
 
 
         # Close invoice if relevant
         # 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(
             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()
         self.save()
 
 
     def __unicode__(self):
     def __unicode__(self):
@@ -456,31 +636,31 @@ class Payment(models.Model):
 # There can be for example an allocation of 3.14€ from P to I.
 # There can be for example an allocation of 3.14€ from P to I.
 class PaymentAllocation(models.Model):
 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,
                                 null=False, blank=False,
                                 related_name='allocations')
                                 related_name='allocations')
     payment = models.ForeignKey(Payment, verbose_name='facture associée',
     payment = models.ForeignKey(Payment, verbose_name='facture associée',
                                 null=False, blank=False,
                                 null=False, blank=False,
                                 related_name='allocations')
                                 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')
                                  verbose_name='montant')
 
 
 
 
-def get_active_payment_and_invoices(member):
+def get_active_payment_and_bills(member):
 
 
     # Fetch relevant and active payments / invoices
     # Fetch relevant and active payments / invoices
     # and sort then by chronological order : olders first, newers last.
     # 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")]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
 
     # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
     # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
     # conflict / trouble invoices)
     # conflict / trouble invoices)
 
 
     active_payments = [p for p in this_member_payments if p.amount_not_allocated()    > 0]
     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):
 def update_accounting_for_member(member):
@@ -495,12 +675,12 @@ def update_accounting_for_member(member):
     accounting_log.info(
     accounting_log.info(
         "Member {} current balance is {} ...".format(member, member.balance))
         "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")]
     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)
                                      this_member_payments)
     member.save()
     member.save()
 
 
@@ -508,22 +688,22 @@ def update_accounting_for_member(member):
         member, member.balance))
         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é
     Rapproche des factures et des paiements qui sont actifs (paiement non alloué
     ou factures non entièrement payées) automatiquement.
     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 == []:
     if active_payments == []:
         accounting_log.info(
         accounting_log.info(
             "(No active payment for {}.".format(member)
             "(No active payment for {}.".format(member)
-            + " No invoice/payment reconciliation needed.).")
+            + " No bill/payment reconciliation needed.).")
         return
         return
-    elif active_invoices == []:
+    elif active_bills == []:
         accounting_log.info(
         accounting_log.info(
-            "(No active invoice for {}. No invoice/payment ".format(member) +
+            "(No active bill for {}. No bill/payment ".format(member) +
             "reconciliation needed.).")
             "reconciliation needed.).")
         return
         return
 
 
@@ -531,32 +711,32 @@ def reconcile_invoices_and_payments(member):
         "Initiating reconciliation between invoice and payments for {}".format(
         "Initiating reconciliation between invoice and payments for {}".format(
             member))
             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]
         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 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(
             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:
         else:
-            i = active_invoices[0]
+            i = active_bills[0]
 
 
         # TODO : should add an assert that the ammount not allocated / remaining to
         # TODO : should add an assert that the ammount not allocated / remaining to
         # pay is lower before and after calling the allocate_to_invoice
         # 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 == []:
     if active_payments == []:
         accounting_log.info("No more active payment. Nothing to reconcile anymore.")
         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.")
         accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
     return
     return
 
 
@@ -594,35 +774,34 @@ def payment_changed(sender, instance, created, **kwargs):
         update_accounting_for_member(instance.member)
         update_accounting_for_member(instance.member)
 
 
 
 
-@receiver(post_save, sender=Invoice)
+@receiver(post_save, sender=Bill)
 @disable_for_loaddata
 @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)
 @receiver(post_delete, sender=PaymentAllocation)
 def paymentallocation_deleted(sender, instance, **kwargs):
 def paymentallocation_deleted(sender, instance, **kwargs):
 
 
-    invoice = instance.invoice
+    bill = instance.bill
 
 
     # Reopen invoice if relevant
     # 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)
 @receiver(post_delete, sender=Payment)
@@ -637,10 +816,10 @@ def payment_deleted(sender, instance, **kwargs):
     if member is None:
     if member is None:
         return
         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")]
     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)
                                      this_member_payments)
     member.save()
     member.save()
 
 

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

@@ -4,7 +4,7 @@
     {% if not original.validated %}
     {% if not original.validated %}
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
     {% elif original.validated %}
     {% 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 %}
     {% endif %}
     {{ block.super }}
     {{ block.super }}
 {% endblock %}
 {% 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

@@ -3,11 +3,11 @@
 {% block content %}
 {% block content %}
 <div class="row">
 <div class="row">
     <div class="large-8 columns">
     <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>
     <div class="large-4 columns">
     <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>
 </div>
 </div>
 
 
@@ -21,7 +21,7 @@
         </tr>
         </tr>
     </thead>
     </thead>
     <tbody>
     <tbody>
-        {% for detail in invoice.details.all %}
+        {% for detail in bill.details.all %}
         <tr>
         <tr>
             <td>{{ detail.label }}
             <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>
                 {% 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>
@@ -32,18 +32,18 @@
         {% endfor %}
         {% endfor %}
         <tr class="total">
         <tr class="total">
             <td class="" colspan="3">Total TTC</td>
             <td class="" colspan="3">Total TTC</td>
-            <td class="total">{{ invoice.amount }}€</td>
+            <td class="total">{{ bill.amount }}€</td>
         </tr>
         </tr>
     </tbody>
     </tbody>
 </table>
 </table>
 
 
 <p>
 <p>
-  Facture à payer avant le {{ invoice.date_due }}.
+  Facture à payer avant le {{ bill.date_due }}.
 </p>
 </p>
 
 
 <h3>Règlement</h3>
 <h3>Règlement</h3>
 
 
-{% if invoice.payments.exists %}
+{% if bill.payments.exists %}
     <table id="invoice-payments" class="invoice-table full-width">
     <table id="invoice-payments" class="invoice-table full-width">
         <thead>
         <thead>
             <tr>
             <tr>
@@ -53,7 +53,7 @@
             </tr>
             </tr>
         </thead>
         </thead>
         <tbody>
         <tbody>
-            {% for payment in invoice.payments.all %}
+            {% for payment in bill.payments.all %}
             <tr class="payment">
             <tr class="payment">
                 <td>{{ payment.get_payment_mean_display }}</td>
                 <td>{{ payment.get_payment_mean_display }}</td>
                 <td>{{ payment.date }}</td>
                 <td>{{ payment.date }}</td>
@@ -62,13 +62,13 @@
             {% endfor %}
             {% endfor %}
             <tr class="total">
             <tr class="total">
                 <td class="" colspan="2">Reste à payer</td>
                 <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>
             </tr>
         </tbody>
         </tbody>
     </table>
     </table>
 {% endif %}
 {% endif %}
 
 
-{% if invoice.amount_remaining_to_pay > 0 %}
+{% if bill.amount_remaining_to_pay > 0 %}
     <div id="payment-howto" class="panel">
     <div id="payment-howto" class="panel">
         {% include "billing/payment_howto.html" %}
         {% include "billing/payment_howto.html" %}
     </div>
     </div>

+ 17 - 179
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>
     <thead>
       <tr>
       <tr>
         <th class="cell-label cell--empty"></th>
         <th class="cell-label cell--empty"></th>
@@ -167,7 +12,7 @@
       </tr>
       </tr>
     </thead>
     </thead>
     <tbody>
     <tbody>
-      {% for detail in invoice.details.all %}
+      {% for detail in bill.details.all %}
       <tr>
       <tr>
         <td class="cell-label">
         <td class="cell-label">
           <p>
           <p>
@@ -189,32 +34,25 @@
         <td class="cell-total cell--money">{{ detail.total }}€</td>
         <td class="cell-total cell--money">{{ detail.total }}€</td>
       </tr>
       </tr>
       {% endfor %}
       {% endfor %}
-      
+
       <tr>
       <tr>
         <td class="cell-result cell--empty"></td>
         <td class="cell-result cell--empty"></td>
         <td class="result-label " colspan="3">Total HT</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>
       <tr>
       <tr>
         <td class="cell-result cell--empty"></td>
         <td class="cell-result cell--empty"></td>
         <td class="cell-result result-label" colspan="3">Total TTC</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>
       </tr>
-       
-    </tbody>
-  </table>
-  <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>
+    </tbody>
+</table>
+<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 freezegun import freeze_time
 from coin.members.tests import MemberTestsUtils
 from coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
 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.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_member_invoice_for_a_period
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_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),
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
                                tax=10)
 
 
-        self.assertEqual(invoice.amount(), 111)
+        self.assertEqual(invoice.amount, 111)
 
 
     def test_invoice_partial_payment(self):
     def test_invoice_partial_payment(self):
         invoice = Invoice(member=self.member)
         invoice = Invoice(member=self.member)
@@ -151,7 +151,7 @@ class BillingInvoiceCreationTests(TestCase):
 
 
         self.assertEqual(invoice.status, 'open')
         self.assertEqual(invoice.status, 'open')
         p1 = Payment.objects.create(member=self.member,
         p1 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
+                                    bill=invoice,
                                     payment_mean='cash',
                                     payment_mean='cash',
                                     amount=10)
                                     amount=10)
         p1.save()
         p1.save()
@@ -160,7 +160,7 @@ class BillingInvoiceCreationTests(TestCase):
         self.assertEqual(invoice.status, 'open')
         self.assertEqual(invoice.status, 'open')
 
 
         p2 = Payment.objects.create(member=self.member,
         p2 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
+                                    bill=invoice,
                                     payment_mean='cash',
                                     payment_mean='cash',
                                     amount=90)
                                     amount=90)
         p2.save()
         p2.save()
@@ -366,7 +366,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
         InvoiceDetail.objects.create(label="superservice",
                                      amount="15.0",
                                      amount="15.0",
-                                     invoice=invoice)
+                                     bill=invoice)
         invoice.validate()
         invoice.validate()
 
 
         # Second facture
         # Second facture
@@ -374,7 +374,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
         InvoiceDetail.objects.create(label="superservice",
                                      amount="42",
                                      amount="42",
-                                     invoice=invoice2)
+                                     bill=invoice2)
         invoice2.validate()
         invoice2.validate()
 
 
         # Payment
         # Payment
@@ -387,3 +387,160 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
         johndoe.delete()
         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(
 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"),
     # 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.shortcuts import render, get_object_or_404
 from django.core.exceptions import PermissionDenied
 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:
     try:
-        return Invoice.objects.get(pk=id)
+        return Bill.objects.get(pk=id).as_child()
     except:
     except:
         return get_object_or_404(Invoice, number=id)
         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:
        and not request.user.is_superuser:
-        raise PermissionDenied
+        raise PermissionDenied

+ 21 - 16
coin/billing/views.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
-from django.http import HttpResponse, HttpResponseRedirect
+from django.http import HttpResponse
 from django.template import RequestContext
 from django.template import RequestContext
 from django.shortcuts import render
 from django.shortcuts import render
 from django.contrib import messages
 from django.contrib import messages
@@ -11,33 +11,38 @@ from sendfile import sendfile
 from coin.billing.models import Invoice
 from coin.billing.models import Invoice
 from coin.members.models import Member
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
 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 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)
                     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

+ 3 - 18
coin/members/admin.py

@@ -15,25 +15,16 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 from django.utils.html import format_html
 
 
 from coin.members.models import (
 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.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 from coin.utils import delete_selected
 import autocomplete_light
 import autocomplete_light
 
 
-
 class CryptoKeyInline(admin.StackedInline):
 class CryptoKeyInline(admin.StackedInline):
     model = CryptoKey
     model = CryptoKey
     extra = 0
     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):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     model = OfferSubscription
     extra = 0
     extra = 0
@@ -88,7 +79,7 @@ class MemberAdmin(UserAdmin):
                     'nickname', 'organization_name', 'email',
                     'nickname', 'organization_name', 'email',
                     'end_date_of_membership')
                     'end_date_of_membership')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
-    list_filter = ('status', MembershipFeeFilter)
+    list_filter = ()
     search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     ordering = ('status', 'username')
     ordering = ('status', 'username')
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
@@ -143,7 +134,7 @@ class MemberAdmin(UserAdmin):
 
 
     save_on_top = True
     save_on_top = True
 
 
-    inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
+    inlines = [CryptoKeyInline, OfferSubscriptionInline]
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         qs = super(MemberAdmin, self).get_queryset(request)
         qs = super(MemberAdmin, self).get_queryset(request)
@@ -258,11 +249,6 @@ class MemberAdmin(UserAdmin):
     bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
     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):
 class RowLevelPermissionAdmin(admin.ModelAdmin):
     def get_changeform_initial_data(self, request):
     def get_changeform_initial_data(self, request):
         return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
         return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
@@ -270,7 +256,6 @@ class RowLevelPermissionAdmin(admin.ModelAdmin):
 
 
 
 
 admin.site.register(Member, MemberAdmin)
 admin.site.register(Member, MemberAdmin)
-admin.site.register(MembershipFee, MembershipFeeAdmin)
 # admin.site.unregister(Group)
 # admin.site.unregister(Group)
 # admin.site.register(LdapUser, LdapUserAdmin)
 # admin.site.register(LdapUser, LdapUserAdmin)
 admin.site.register(RowLevelPermission, RowLevelPermissionAdmin)
 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

@@ -95,10 +95,11 @@ class Member(CoinLdapSyncMixin, AbstractUser):
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
                         blank=True,
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
+
     send_membership_fees_email = models.BooleanField(
     send_membership_fees_email = models.BooleanField(
         default=True, verbose_name='relance de cotisation',
         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.)')
         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')
                                   verbose_name='account balance')
 
 
     objects = MemberManager()
     objects = MemberManager()
@@ -139,7 +140,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
 
     # Renvoie la date de fin de la dernière cotisation du membre
     # Renvoie la date de fin de la dernière cotisation du membre
     def end_date_of_membership(self):
     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']
         return aggregate['end']
     end_date_of_membership.short_description = "Date de fin d'adhésion"
     end_date_of_membership.short_description = "Date de fin d'adhésion"
 
 

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

@@ -6,12 +6,12 @@
 	<h2>Balance : {{ balance|floatformat }} €</h2>
 	<h2>Balance : {{ balance|floatformat }} €</h2>
 {% endif %}
 {% endif %}
 
 
-<h2>Mes factures</h2>
+<h2>Mes factures et reçus</h2>
 
 
 <table id="member_invoices" class="full-width">
 <table id="member_invoices" class="full-width">
     <thead>
     <thead>
         <tr>
         <tr>
-            <th>Numéro</th>
+            <th>Référence</th>
             <th>Date</th>
             <th>Date</th>
             <th>Montant</th>
             <th>Montant</th>
             <th>Reste à payer</th>
             <th>Reste à payer</th>
@@ -21,11 +21,11 @@
     <tbody>
     <tbody>
         {% for invoice in invoices %}
         {% for invoice in invoices %}
         <tr>
         <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.date }}</td>
             <td>{{ invoice.amount }}</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.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>
         </tr>
         {% empty %}
         {% empty %}
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>
         <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 import mail, management
 from django.core.exceptions import ValidationError
 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
 from coin.validation import chatroom_url_validator
 
 
 
 
@@ -298,67 +298,6 @@ class MemberTests(TestCase):
 
 
         member.delete()
         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):
     def test_member_cant_be_created_without_names(self):
         """
         """
         Test qu'un membre ne peut pas être créé sans "noms"
         Test qu'un membre ne peut pas être créé sans "noms"
@@ -374,7 +313,6 @@ class MemberTests(TestCase):
             member.save()
             member.save()
 
 
 
 
-
 class MemberAdminTests(TestCase):
 class MemberAdminTests(TestCase):
 
 
     def setUp(self):
     def setUp(self):
@@ -417,83 +355,6 @@ class MemberAdminTests(TestCase):
         member.delete()
         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):
 class MemberTestsUtils(object):
 
 
     @staticmethod
     @staticmethod
@@ -510,21 +371,3 @@ class TestValidators(TestCase):
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             chatroom_url_validator('http://#faimaison@irc.geeknode.org')
             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

@@ -6,6 +6,7 @@ from django.shortcuts import render
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth.decorators import login_required
 from django.conf import settings
 from django.conf import settings
 from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
+from coin.billing.models import Bill
 
 
 @login_required
 @login_required
 def index(request):
 def index(request):
@@ -53,7 +54,7 @@ def subscriptions(request):
 @login_required
 @login_required
 def invoices(request):
 def invoices(request):
     balance  = request.user.balance
     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')
     payments = request.user.payments.filter().order_by('-date')
 
 
     return render(request, 'members/invoices.html',
     return render(request, 'members/invoices.html',